feat: Morning NOC Summary adaptive card for Teams
- Add MorningSummaryService with Zabbix aggregation and adaptive card builder - Add webhook delivery system with Teams incoming webhooks - Add admin UI at /admin/morning-summary for webhook/config management - Add API routes: /send, /test, /webhooks, /webhooks/[id], /config, /history - Register morning-summary cron job in SyncScheduler (Mon-Fri 6:30 AM) - Add outages_only filter (Unavailable triggers only) - Fix host resolution: use getTriggerEnabledHosts to exclude disabled hosts - Fix resolved events: event.get value:1 scoped to window with r_eventid filter - Remove emojis from fact rows and section headers in card - Remove Open Zabbix button (duplicate of View Problems) - Add migrations: morning_summary_config + morning_summaries tables - Add outages_only column to morning_summary_config
This commit is contained in:
parent
19605f82aa
commit
c518eefdb2
61 changed files with 11236 additions and 237 deletions
467
app/admin/morning-summary/page.tsx
Normal file
467
app/admin/morning-summary/page.tsx
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Send, RefreshCw, Trash2, Plus, CheckCircle2, XCircle,
|
||||
AlertTriangle, Clock, Loader2, ChevronDown, ChevronUp, ToggleLeft, ToggleRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface WebhookConfig {
|
||||
id: number;
|
||||
label: string;
|
||||
webhook_url: string;
|
||||
enabled: boolean;
|
||||
last_delivered_at: string | null;
|
||||
last_status: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface SummaryConfig {
|
||||
weekend_suppression: boolean;
|
||||
monday_extended_window: boolean;
|
||||
severity_filter: number;
|
||||
outages_only: boolean;
|
||||
}
|
||||
|
||||
interface SummaryRow {
|
||||
id: number;
|
||||
generated_at: string;
|
||||
open_count: number;
|
||||
resolved_count: number;
|
||||
mttr_minutes: number | null;
|
||||
is_weekend_window: boolean;
|
||||
delivery_status: Record<string, { success: boolean; httpStatus?: number; error?: string }>;
|
||||
card_payload?: object;
|
||||
window_from?: string;
|
||||
window_to?: string;
|
||||
clients_affected?: string[];
|
||||
}
|
||||
|
||||
function fmtDate(d: string | null) {
|
||||
if (!d) return 'Never';
|
||||
const date = new Date(d);
|
||||
const diff = Date.now() - date.getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'Just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string | null }) {
|
||||
if (!status) return <span className="text-xs text-muted-foreground">Never sent</span>;
|
||||
if (status === 'success') return (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<CheckCircle2 className="h-3 w-3" /> Success
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-red-500">
|
||||
<XCircle className="h-3 w-3" /> Failed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MorningSummaryPage() {
|
||||
const [webhooks, setWebhooks] = useState<WebhookConfig[]>([]);
|
||||
const [config, setConfig] = useState<SummaryConfig | null>(null);
|
||||
const [history, setHistory] = useState<SummaryRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [testingId, setTestingId] = useState<number | null>(null);
|
||||
const [toast, setToast] = useState<{ msg: string; ok: boolean } | null>(null);
|
||||
const [cardExpanded, setCardExpanded] = useState(false);
|
||||
const [showAddWebhook, setShowAddWebhook] = useState(false);
|
||||
const [newLabel, setNewLabel] = useState('');
|
||||
const [newUrl, setNewUrl] = useState('');
|
||||
const [addingWebhook, setAddingWebhook] = useState(false);
|
||||
|
||||
const showToast = (msg: string, ok: boolean) => {
|
||||
setToast({ msg, ok });
|
||||
setTimeout(() => setToast(null), 4000);
|
||||
};
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [wRes, cRes, hRes] = await Promise.all([
|
||||
fetch('/api/notifications/morning-summary/webhooks'),
|
||||
fetch('/api/notifications/morning-summary/config'),
|
||||
fetch('/api/notifications/morning-summary/history'),
|
||||
]);
|
||||
const [wData, cData, hData] = await Promise.all([wRes.json(), cRes.json(), hRes.json()]);
|
||||
setWebhooks(wData.webhooks ?? []);
|
||||
setConfig(cData.config ?? null);
|
||||
setHistory(hData.history ?? []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchAll(); }, [fetchAll]);
|
||||
|
||||
const handleSendAll = async () => {
|
||||
setSending(true);
|
||||
try {
|
||||
const res = await fetch('/api/notifications/morning-summary/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
const ok = data.results?.filter((r: any) => r.success).length ?? 0;
|
||||
const fail = data.results?.filter((r: any) => !r.success).length ?? 0;
|
||||
showToast(`Sent — ${ok} succeeded, ${fail} failed`, fail === 0);
|
||||
fetchAll();
|
||||
} catch (e) {
|
||||
showToast(String(e), false);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (webhookId: number, label: string) => {
|
||||
setTestingId(webhookId);
|
||||
try {
|
||||
const res = await fetch('/api/notifications/morning-summary/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ webhookId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
showToast(data.result?.success ? `✅ Test sent to ${label}` : `❌ Test failed: ${data.result?.error ?? data.error}`, data.result?.success);
|
||||
fetchAll();
|
||||
} catch (e) {
|
||||
showToast(String(e), false);
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleWebhook = async (webhook: WebhookConfig) => {
|
||||
try {
|
||||
await fetch(`/api/notifications/morning-summary/webhooks/${webhook.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: !webhook.enabled }),
|
||||
});
|
||||
setWebhooks(prev => prev.map(w => w.id === webhook.id ? { ...w, enabled: !w.enabled } : w));
|
||||
} catch (e) {
|
||||
showToast(String(e), false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteWebhook = async (id: number) => {
|
||||
if (!confirm('Delete this webhook?')) return;
|
||||
try {
|
||||
await fetch(`/api/notifications/morning-summary/webhooks/${id}`, { method: 'DELETE' });
|
||||
setWebhooks(prev => prev.filter(w => w.id !== id));
|
||||
} catch (e) {
|
||||
showToast(String(e), false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddWebhook = async () => {
|
||||
if (!newLabel.trim() || !newUrl.trim()) return;
|
||||
setAddingWebhook(true);
|
||||
try {
|
||||
const res = await fetch('/api/notifications/morning-summary/webhooks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label: newLabel.trim(), webhook_url: newUrl.trim() }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
setWebhooks(prev => [...prev, data.webhook]);
|
||||
setNewLabel('');
|
||||
setNewUrl('');
|
||||
setShowAddWebhook(false);
|
||||
showToast('Webhook added', true);
|
||||
} catch (e) {
|
||||
showToast(String(e), false);
|
||||
} finally {
|
||||
setAddingWebhook(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfigToggle = async (field: keyof SummaryConfig) => {
|
||||
if (!config) return;
|
||||
const updated = { ...config, [field]: !config[field] };
|
||||
setConfig(updated);
|
||||
try {
|
||||
await fetch('/api/notifications/morning-summary/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: updated[field] }),
|
||||
});
|
||||
} catch (e) {
|
||||
showToast(String(e), false);
|
||||
setConfig(config);
|
||||
}
|
||||
};
|
||||
|
||||
const latestSummary = history[0] ?? null;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-8">
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-sm font-medium flex items-center gap-2 ${toast.ok ? 'bg-green-500/10 border border-green-500/30 text-green-400' : 'bg-red-500/10 border border-red-500/30 text-red-400'}`}>
|
||||
{toast.ok ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">☀️ Morning NOC Summary</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Scheduled 6:30 AM Mon–Fri · Posts to Teams channels via webhook</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={fetchAll}><RefreshCw className="h-4 w-4 mr-1" /> Refresh</Button>
|
||||
<Button size="sm" onClick={handleSendAll} disabled={sending}>
|
||||
{sending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Send className="h-4 w-4 mr-1" />}
|
||||
Send Now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last Run Stats */}
|
||||
{latestSummary && (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Last Run</h2>
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" /> {fmtDate(latestSummary.generated_at)}
|
||||
{latestSummary.is_weekend_window && <span className="ml-2 px-1.5 py-0.5 bg-blue-500/10 text-blue-400 rounded text-xs">Weekend</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className={`text-2xl font-bold ${latestSummary.open_count > 0 ? 'text-red-400' : 'text-muted-foreground'}`}>{latestSummary.open_count}</div>
|
||||
<div className="text-xs text-muted-foreground">Open</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`text-2xl font-bold ${latestSummary.resolved_count > 0 ? 'text-green-400' : 'text-muted-foreground'}`}>{latestSummary.resolved_count}</div>
|
||||
<div className="text-xs text-muted-foreground">Resolved</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">{latestSummary.mttr_minutes != null ? `${latestSummary.mttr_minutes}m` : '—'}</div>
|
||||
<div className="text-xs text-muted-foreground">Avg MTTR</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Delivery results */}
|
||||
{Object.keys(latestSummary.delivery_status).length > 0 && (
|
||||
<div className="pt-2 border-t space-y-1">
|
||||
<p className="text-xs text-muted-foreground font-medium">Delivery</p>
|
||||
{Object.entries(latestSummary.delivery_status).map(([wid, r]) => {
|
||||
const webhook = webhooks.find(w => w.id === parseInt(wid));
|
||||
return (
|
||||
<div key={wid} className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">{webhook?.label ?? `Webhook #${wid}`}</span>
|
||||
{r.success
|
||||
? <span className="text-green-500 flex items-center gap-1"><CheckCircle2 className="h-3 w-3" /> Delivered</span>
|
||||
: <span className="text-red-500 flex items-center gap-1"><XCircle className="h-3 w-3" /> {r.error ?? `HTTP ${r.httpStatus}`}</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* Card preview toggle */}
|
||||
{latestSummary.card_payload && (
|
||||
<div className="pt-2 border-t">
|
||||
<button
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setCardExpanded(v => !v)}
|
||||
>
|
||||
{cardExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
{cardExpanded ? 'Hide' : 'Show'} card payload
|
||||
</button>
|
||||
{cardExpanded && (
|
||||
<pre className="mt-2 text-xs bg-muted/30 rounded p-3 overflow-auto max-h-64 text-muted-foreground">
|
||||
{JSON.stringify(latestSummary.card_payload, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Webhooks */}
|
||||
<div className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Webhooks</h2>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAddWebhook(v => !v)}>
|
||||
<Plus className="h-3 w-3 mr-1" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showAddWebhook && (
|
||||
<div className="rounded-md border border-dashed p-3 space-y-2 bg-muted/10">
|
||||
<input
|
||||
className="w-full text-sm bg-background border rounded px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder="Label (e.g. Technical / On-Call)"
|
||||
value={newLabel}
|
||||
onChange={e => setNewLabel(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="w-full text-sm bg-background border rounded px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
placeholder="Webhook URL"
|
||||
value={newUrl}
|
||||
onChange={e => setNewUrl(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleAddWebhook} disabled={addingWebhook || !newLabel || !newUrl}>
|
||||
{addingWebhook ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Add Webhook'}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setShowAddWebhook(false); setNewLabel(''); setNewUrl(''); }}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webhooks.length === 0 && !showAddWebhook && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No webhooks configured. Add one above.</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{webhooks.map(webhook => (
|
||||
<div key={webhook.id} className={`flex items-center justify-between rounded-md border px-3 py-2.5 ${webhook.enabled ? 'bg-background' : 'bg-muted/20 opacity-60'}`}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{webhook.label}</span>
|
||||
{webhook.enabled
|
||||
? <span className="text-xs px-1.5 py-0.5 bg-green-500/10 text-green-400 rounded">Enabled</span>
|
||||
: <span className="text-xs px-1.5 py-0.5 bg-muted/40 text-muted-foreground rounded">Disabled</span>
|
||||
}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5">
|
||||
<StatusBadge status={webhook.last_status} />
|
||||
{webhook.last_delivered_at && (
|
||||
<span className="text-xs text-muted-foreground">{fmtDate(webhook.last_delivered_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={testingId === webhook.id}
|
||||
onClick={() => handleTest(webhook.id, webhook.label)}
|
||||
>
|
||||
{testingId === webhook.id ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Test'}
|
||||
</Button>
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleToggleWebhook(webhook)}
|
||||
title={webhook.enabled ? 'Disable' : 'Enable'}
|
||||
>
|
||||
{webhook.enabled
|
||||
? <ToggleRight className="h-4 w-4 text-green-500" />
|
||||
: <ToggleLeft className="h-4 w-4" />
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-red-500/10 transition-colors text-muted-foreground hover:text-red-500"
|
||||
onClick={() => handleDeleteWebhook(webhook.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule Config */}
|
||||
{config && (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Schedule Settings</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Weekend Suppression</p>
|
||||
<p className="text-xs text-muted-foreground">Skip Saturday & Sunday (cron already limits to Mon–Fri)</p>
|
||||
</div>
|
||||
<button onClick={() => handleConfigToggle('weekend_suppression')} className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
{config.weekend_suppression
|
||||
? <ToggleRight className="h-6 w-6 text-green-500" />
|
||||
: <ToggleLeft className="h-6 w-6" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Monday Extended Window</p>
|
||||
<p className="text-xs text-muted-foreground">On Mondays, extend window to cover the full weekend (Fri 6 PM → Mon 6:30 AM)</p>
|
||||
</div>
|
||||
<button onClick={() => handleConfigToggle('monday_extended_window')} className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
{config.monday_extended_window
|
||||
? <ToggleRight className="h-6 w-6 text-green-500" />
|
||||
: <ToggleLeft className="h-6 w-6" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Outages Only</p>
|
||||
<p className="text-xs text-muted-foreground">Only show "Unavailable" problems — filter out slow response and other non-outage alerts</p>
|
||||
</div>
|
||||
<button onClick={() => handleConfigToggle('outages_only')} className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
{config.outages_only
|
||||
? <ToggleRight className="h-6 w-6 text-green-500" />
|
||||
: <ToggleLeft className="h-6 w-6" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Run History */}
|
||||
{history.length > 0 && (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Recent Runs</h2>
|
||||
<div className="space-y-1">
|
||||
{history.map(row => {
|
||||
const statusEntries = Object.values(row.delivery_status);
|
||||
const allOk = statusEntries.length > 0 && statusEntries.every((r: any) => r.success);
|
||||
const anyFail = statusEntries.some((r: any) => !r.success);
|
||||
return (
|
||||
<div key={row.id} className="flex items-center justify-between text-sm py-1.5 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{allOk && <CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />}
|
||||
{anyFail && <AlertTriangle className="h-3.5 w-3.5 text-yellow-500 shrink-0" />}
|
||||
{statusEntries.length === 0 && <Clock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
|
||||
<span className="text-muted-foreground">{fmtDate(row.generated_at)}</span>
|
||||
{row.is_weekend_window && <span className="text-xs px-1.5 py-0.5 bg-blue-500/10 text-blue-400 rounded">Weekend</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={row.open_count > 0 ? 'text-red-400' : 'text-muted-foreground'}>
|
||||
{row.open_count} open
|
||||
</span>
|
||||
<span className={row.resolved_count > 0 ? 'text-green-400' : 'text-muted-foreground'}>
|
||||
{row.resolved_count} resolved
|
||||
</span>
|
||||
{row.mttr_minutes != null && (
|
||||
<span className="text-muted-foreground">{row.mttr_minutes}m MTTR</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -37,8 +37,13 @@ import {
|
|||
Building2,
|
||||
Server,
|
||||
GitFork,
|
||||
Plus,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Network,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { HostManager } from '@/components/zabbix/host-manager';
|
||||
|
||||
type SyncMode = 'all' | 'client' | 'site';
|
||||
|
||||
|
|
@ -117,12 +122,37 @@ export default function ZabbixWanPage() {
|
|||
const abortRef = useRef<AbortController | null>(null);
|
||||
const tableBottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Manual host creation state
|
||||
const [manualOpen, setManualOpen] = useState(false);
|
||||
const [manualIp, setManualIp] = useState('');
|
||||
const [manualSiteName, setManualSiteName] = useState('');
|
||||
const [manualCompanyId, setManualCompanyId] = useState<string>('');
|
||||
const [manualDryRun, setManualDryRun] = useState(true);
|
||||
const [manualRunning, setManualRunning] = useState(false);
|
||||
const [manualResult, setManualResult] = useState<{
|
||||
action: string;
|
||||
dryRun: boolean;
|
||||
siteName: string;
|
||||
ip: string;
|
||||
companyName: string | null;
|
||||
isp: string | null;
|
||||
asn: string | null;
|
||||
hostId: string | null;
|
||||
error?: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/rmm/site-mappings')
|
||||
.then((r) => r.json())
|
||||
.then((d) => setMappings(d.mappings ?? []))
|
||||
.catch(() => toast.error('Failed to load site mappings'))
|
||||
.finally(() => setLoadingMappings(false));
|
||||
|
||||
// Pre-fill manual IP with the user's current public IP (client-side)
|
||||
fetch('https://ipinfo.io/json')
|
||||
.then((r) => r.json())
|
||||
.then((d) => { if (d.ip) setManualIp(d.ip); })
|
||||
.catch(() => { /* ignore */ });
|
||||
}, []);
|
||||
|
||||
// Scroll results table as rows stream in
|
||||
|
|
@ -219,6 +249,42 @@ export default function ZabbixWanPage() {
|
|||
setRunning(false);
|
||||
};
|
||||
|
||||
// Manual host creation
|
||||
const ipv4Valid = /^(\d{1,3}\.){3}\d{1,3}$/.test(manualIp);
|
||||
const canCreateManual = !manualRunning && manualIp.trim() !== '' && manualSiteName.trim() !== '' && ipv4Valid;
|
||||
|
||||
const handleManualCreate = async () => {
|
||||
setManualRunning(true);
|
||||
setManualResult(null);
|
||||
try {
|
||||
const resp = await fetch('/api/zabbix/create-host', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ip: manualIp.trim(),
|
||||
siteName: manualSiteName.trim(),
|
||||
companyId: manualCompanyId && manualCompanyId !== 'none' ? Number(manualCompanyId) : undefined,
|
||||
dryRun: manualDryRun,
|
||||
}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
setManualResult({ action: 'error', dryRun: manualDryRun, siteName: manualSiteName, ip: manualIp, companyName: null, isp: null, asn: null, hostId: null, error: data.error });
|
||||
toast.error(data.error ?? 'Failed to create host');
|
||||
} else {
|
||||
setManualResult(data);
|
||||
if (data.action === 'created') toast.success(`Host "${data.siteName}" created (id=${data.hostId})`);
|
||||
else if (data.action === 'updated') toast.success(`Host "${data.siteName}" updated (id=${data.hostId})`);
|
||||
else if (data.action === 'skipped') toast.info('Dry run — no changes written to Zabbix');
|
||||
}
|
||||
} catch (err) {
|
||||
setManualResult({ action: 'error', dryRun: manualDryRun, siteName: manualSiteName, ip: manualIp, companyName: null, isp: null, asn: null, hostId: null, error: String(err) });
|
||||
toast.error('Request failed: ' + String(err));
|
||||
} finally {
|
||||
setManualRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-6xl space-y-6">
|
||||
{/* Header */}
|
||||
|
|
@ -410,6 +476,142 @@ export default function ZabbixWanPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Manual Host Creation */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="pb-4 cursor-pointer select-none"
|
||||
onClick={() => setManualOpen((v) => !v)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{manualOpen ? <ChevronDown className="w-4 h-4 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 text-muted-foreground" />}
|
||||
<Network className="w-4 h-4" />
|
||||
<CardTitle className="text-base">Manual Host</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="mt-0">Create a Zabbix host from an IP address — for testing or clients not in RMM</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{manualOpen && (
|
||||
<CardContent className="space-y-5 pt-0">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* IP Address */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-ip" className="text-sm font-medium">IP Address</Label>
|
||||
<Input
|
||||
id="manual-ip"
|
||||
placeholder="203.0.113.42"
|
||||
value={manualIp}
|
||||
onChange={(e) => setManualIp(e.target.value)}
|
||||
className={`w-56 font-mono ${manualIp && !ipv4Valid ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
{manualIp && !ipv4Valid && (
|
||||
<p className="text-xs text-destructive">Enter a valid IPv4 address</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Site Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-site" className="text-sm font-medium">Site Name</Label>
|
||||
<Input
|
||||
id="manual-site"
|
||||
placeholder="Acme Corp - Main Office"
|
||||
value={manualSiteName}
|
||||
onChange={(e) => setManualSiteName(e.target.value)}
|
||||
className="w-80"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Becomes the Zabbix host display name</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Client selector */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium flex items-center gap-1.5">
|
||||
<Building2 className="w-3.5 h-3.5" /> Client (optional)
|
||||
</Label>
|
||||
<Select value={manualCompanyId} onValueChange={setManualCompanyId} disabled={loadingMappings}>
|
||||
<SelectTrigger className="w-80">
|
||||
<SelectValue placeholder={loadingMappings ? 'Loading…' : 'No client (unlinked)'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No client (unlinked)</SelectItem>
|
||||
{companies.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Links the host to an Autotask client with macros, tags, and a client host group
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dry-run + actions */}
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
id="manual-dry-run"
|
||||
checked={manualDryRun}
|
||||
onCheckedChange={setManualDryRun}
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="manual-dry-run" className="text-sm font-medium cursor-pointer">
|
||||
Dry run
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Preview only — no writes to Zabbix
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleManualCreate}
|
||||
disabled={!canCreateManual}
|
||||
className="gap-2"
|
||||
>
|
||||
{manualRunning ? (
|
||||
<><Loader2 className="w-4 h-4 animate-spin" /> Creating…</>
|
||||
) : (
|
||||
<><Plus className="w-4 h-4" /> {manualDryRun ? 'Preview' : 'Create Host'}</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Manual result */}
|
||||
{manualResult && (
|
||||
<div className={`rounded-md border p-4 space-y-2 ${
|
||||
manualResult.action === 'error'
|
||||
? 'border-destructive/30 bg-destructive/5'
|
||||
: manualResult.action === 'created'
|
||||
? 'border-green-500/30 bg-green-500/5'
|
||||
: manualResult.action === 'updated'
|
||||
? 'border-blue-500/30 bg-blue-500/5'
|
||||
: 'border-border bg-muted/20'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<ActionBadge action={manualResult.action} />
|
||||
<span className="font-medium text-sm">{manualResult.siteName}</span>
|
||||
<span className="font-mono text-sm text-muted-foreground">{manualResult.ip}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
|
||||
{manualResult.companyName && <span>Client: <strong>{manualResult.companyName}</strong></span>}
|
||||
{manualResult.isp && <span>ISP: {manualResult.isp}</span>}
|
||||
{manualResult.asn && <span>{manualResult.asn}</span>}
|
||||
{manualResult.hostId && <span>Zabbix ID: <strong className="font-mono">{manualResult.hostId}</strong></span>}
|
||||
{manualResult.dryRun && <span className="italic">Dry run — no changes written</span>}
|
||||
</div>
|
||||
{manualResult.error && (
|
||||
<p className="text-xs text-destructive">{manualResult.error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Host Manager */}
|
||||
<HostManager companies={companies} />
|
||||
|
||||
{/* Results */}
|
||||
{(results.length > 0 || running || fatalError) && (
|
||||
<Card>
|
||||
|
|
|
|||
85
app/api/data/contracts/[id]/services/route.ts
Normal file
85
app/api/data/contracts/[id]/services/route.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Contract Services Detail API
|
||||
* GET /api/data/contracts/[id]/services - Returns a contract with all its service lines
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const contractId = parseInt(id);
|
||||
if (isNaN(contractId)) {
|
||||
return NextResponse.json({ error: 'Invalid contract ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const contractResult = await postgresClient.query(
|
||||
`SELECT ct.*, c.company_name
|
||||
FROM contracts ct
|
||||
LEFT JOIN companies c ON c.id = ct.company_id
|
||||
WHERE ct.id = $1 AND ct.is_deleted = false`,
|
||||
[contractId]
|
||||
);
|
||||
|
||||
if (contractResult.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Contract not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const contract = contractResult.rows[0];
|
||||
|
||||
const servicesResult = await postgresClient.query(
|
||||
`SELECT
|
||||
cs.id,
|
||||
cs.service_id,
|
||||
cs.service_name,
|
||||
cs.description,
|
||||
cs.unit_price,
|
||||
cs.unit_cost,
|
||||
cs.quantity,
|
||||
cs.adjusted_price,
|
||||
cs.period_type,
|
||||
cs.start_date,
|
||||
cs.end_date,
|
||||
s.name AS catalog_name
|
||||
FROM contract_services cs
|
||||
LEFT JOIN autotask_services s ON s.id = cs.service_id
|
||||
WHERE cs.contract_id = $1 AND cs.is_deleted = false
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN COALESCE(cs.service_name, s.name) ILIKE '%workstation%backup%'
|
||||
OR COALESCE(cs.service_name, s.name) ILIKE '%w/ backup%'
|
||||
OR COALESCE(cs.service_name, s.name) ILIKE '%windows server%'
|
||||
OR COALESCE(cs.service_name, s.name) ILIKE '%server virtual%'
|
||||
OR COALESCE(cs.service_name, s.name) ILIKE '%server phys%'
|
||||
OR COALESCE(cs.service_name, s.name) ILIKE '%esxi host%'
|
||||
THEN 0
|
||||
ELSE 1
|
||||
END,
|
||||
COALESCE(cs.service_name, s.name)`,
|
||||
[contractId]
|
||||
);
|
||||
|
||||
const periodLabels: Record<number, string> = {
|
||||
1: 'Monthly',
|
||||
2: 'Quarterly',
|
||||
3: 'Semi-Annual',
|
||||
4: 'Annual',
|
||||
5: 'One-Time',
|
||||
};
|
||||
|
||||
const services = servicesResult.rows.map((row) => ({
|
||||
...row,
|
||||
display_name: row.service_name || row.catalog_name || `Service #${row.service_id}`,
|
||||
period_label: row.period_type ? (periodLabels[row.period_type] ?? `Type ${row.period_type}`) : null,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ contract, services });
|
||||
} catch (error) {
|
||||
console.error('[CONTRACT-SERVICES-API] Error:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch contract services' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
186
app/api/engagement/backfill-meetings/route.ts
Normal file
186
app/api/engagement/backfill-meetings/route.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getMsgraphClient } from '@/lib/services/msgraph-factory';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
|
||||
let backfillInProgress = false;
|
||||
let backfillStatus: {
|
||||
running: boolean;
|
||||
started: string | null;
|
||||
processed: number;
|
||||
total: number;
|
||||
currentUser: string | null;
|
||||
errors: number;
|
||||
done: boolean;
|
||||
log: string[];
|
||||
} = { running: false, started: null, processed: 0, total: 0, currentUser: null, errors: 0, done: false, log: [] };
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(backfillStatus);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (backfillInProgress) {
|
||||
return NextResponse.json({ error: 'Backfill already running' }, { status: 409 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const monthsBack = Math.min(Number(body.monthsBack ?? 12), 24);
|
||||
|
||||
backfillInProgress = true;
|
||||
backfillStatus = {
|
||||
running: true,
|
||||
started: new Date().toISOString(),
|
||||
processed: 0,
|
||||
total: 0,
|
||||
currentUser: null,
|
||||
errors: 0,
|
||||
done: false,
|
||||
log: [],
|
||||
};
|
||||
|
||||
// Run async — don't await
|
||||
runBackfill(monthsBack).finally(() => {
|
||||
backfillInProgress = false;
|
||||
});
|
||||
|
||||
return NextResponse.json({ started: true, monthsBack });
|
||||
}
|
||||
|
||||
async function runBackfill(monthsBack: number) {
|
||||
const log = (msg: string) => {
|
||||
console.log(`[MEETING-BACKFILL] ${msg}`);
|
||||
backfillStatus.log.push(msg);
|
||||
if (backfillStatus.log.length > 200) backfillStatus.log.shift();
|
||||
};
|
||||
|
||||
try {
|
||||
const client = getMsgraphClient();
|
||||
|
||||
// Fetch internal domains for attendee classification
|
||||
const orgDomains = await client.getOrganizationDomains();
|
||||
const internalDomains = new Set(orgDomains);
|
||||
log(`Internal domains: ${[...internalDomains].join(', ')}`);
|
||||
|
||||
// Build contact email index for client matching
|
||||
const contactRows = await postgresClient.query(
|
||||
`SELECT id, company_id, LOWER(email_address) as e1,
|
||||
LOWER(email_address2) as e2, LOWER(email_address3) as e3
|
||||
FROM contacts WHERE (is_deleted = false OR is_deleted IS NULL)`
|
||||
);
|
||||
const contactEmailIndex = new Map<string, { contactId: number; companyId: number | null }>();
|
||||
for (const row of contactRows.rows) {
|
||||
for (const e of [row.e1, row.e2, row.e3]) {
|
||||
if (e && !contactEmailIndex.has(e)) {
|
||||
contactEmailIndex.set(e, { contactId: row.id, companyId: row.company_id });
|
||||
}
|
||||
}
|
||||
}
|
||||
log(`Contact index: ${contactEmailIndex.size} emails`);
|
||||
|
||||
// Get all active graph users
|
||||
const usersResult = await postgresClient.query(
|
||||
`SELECT id, email, display_name FROM graph_users WHERE account_enabled = true ORDER BY display_name`
|
||||
);
|
||||
const users = usersResult.rows;
|
||||
backfillStatus.total = users.length;
|
||||
log(`Users to backfill: ${users.length}, going back ${monthsBack} months`);
|
||||
|
||||
const now = new Date();
|
||||
// Build date range: from (monthsBack months ago, start of month) to 91 days ago
|
||||
// (avoid re-syncing data already covered by the regular 90-day sync)
|
||||
const backfillEnd = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||
const backfillStart = new Date(now.getFullYear(), now.getMonth() - monthsBack, 1);
|
||||
log(`Date range: ${backfillStart.toISOString().slice(0, 10)} → ${backfillEnd.toISOString().slice(0, 10)}`);
|
||||
|
||||
for (const user of users) {
|
||||
backfillStatus.currentUser = user.display_name;
|
||||
try {
|
||||
const events = await client.getUserCalendarEvents(user.id, backfillStart, backfillEnd);
|
||||
let inserted = 0;
|
||||
|
||||
for (const event of events) {
|
||||
if (!event.id) continue;
|
||||
try {
|
||||
const startTime = new Date(event.start.dateTime);
|
||||
const endTime = new Date(event.end.dateTime);
|
||||
const durationMinutes = Math.max(0, Math.round((endTime.getTime() - startTime.getTime()) / 60000));
|
||||
const attendeeCount = event.attendees.length;
|
||||
|
||||
const externalAttendees = event.attendees.filter(a => {
|
||||
const aEmail = (a.emailAddress?.address ?? '').toLowerCase();
|
||||
if (aEmail === user.email.toLowerCase()) return false;
|
||||
const domain = aEmail.split('@')[1];
|
||||
return domain && !internalDomains.has(domain);
|
||||
});
|
||||
|
||||
const meetingResult = await postgresClient.query(
|
||||
`INSERT INTO teams_meetings
|
||||
(graph_event_id, user_email, subject, start_time, end_time,
|
||||
duration_minutes, is_online_meeting, attendee_count, synced_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
|
||||
ON CONFLICT (user_email, graph_event_id) DO UPDATE SET
|
||||
subject = EXCLUDED.subject,
|
||||
start_time = EXCLUDED.start_time,
|
||||
end_time = EXCLUDED.end_time,
|
||||
duration_minutes = EXCLUDED.duration_minutes,
|
||||
is_online_meeting = EXCLUDED.is_online_meeting,
|
||||
attendee_count = EXCLUDED.attendee_count,
|
||||
synced_at = NOW()
|
||||
RETURNING id`,
|
||||
[event.id, user.email, event.subject, startTime, endTime,
|
||||
durationMinutes, event.isOnlineMeeting, attendeeCount]
|
||||
);
|
||||
const meetingId = meetingResult.rows[0]?.id;
|
||||
if (!meetingId) continue;
|
||||
|
||||
await postgresClient.query(
|
||||
`DELETE FROM teams_meeting_attendees WHERE meeting_id = $1`,
|
||||
[meetingId]
|
||||
);
|
||||
|
||||
let clientCount = 0;
|
||||
for (const att of externalAttendees) {
|
||||
const attEmail = (att.emailAddress?.address ?? '').toLowerCase();
|
||||
const attName = att.emailAddress?.name ?? null;
|
||||
const match = attEmail ? contactEmailIndex.get(attEmail) : undefined;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO teams_meeting_attendees
|
||||
(meeting_id, attendee_email, attendee_name, matched_contact_id, matched_company_id)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[meetingId, attEmail || null, attName,
|
||||
match?.contactId ?? null, match?.companyId ?? null]
|
||||
);
|
||||
if (match?.companyId) clientCount++;
|
||||
}
|
||||
|
||||
await postgresClient.query(
|
||||
`UPDATE teams_meetings SET client_attendee_count = $1, has_client_attendees = $2 WHERE id = $3`,
|
||||
[clientCount, clientCount > 0, meetingId]
|
||||
);
|
||||
inserted++;
|
||||
} catch (evErr) {
|
||||
const msg = evErr instanceof Error ? evErr.message : String(evErr);
|
||||
log(` Event error ${event.id}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
log(`${user.display_name}: ${events.length} events, ${inserted} upserted`);
|
||||
} catch (userErr) {
|
||||
const msg = userErr instanceof Error ? userErr.message : String(userErr);
|
||||
log(`${user.display_name}: SKIP — ${msg}`);
|
||||
backfillStatus.errors++;
|
||||
}
|
||||
|
||||
backfillStatus.processed++;
|
||||
}
|
||||
|
||||
log(`Done. ${backfillStatus.processed} users, ${backfillStatus.errors} errors.`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log(`FATAL: ${msg}`);
|
||||
} finally {
|
||||
backfillStatus.running = false;
|
||||
backfillStatus.done = true;
|
||||
backfillStatus.currentUser = null;
|
||||
}
|
||||
}
|
||||
151
app/api/engagement/summary/route.ts
Normal file
151
app/api/engagement/summary/route.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
import { isMsgraphConfigured } from '@/lib/services/msgraph-factory';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const period = searchParams.get('period') || 'D30';
|
||||
|
||||
try {
|
||||
// Get latest snapshot date for this period
|
||||
const latestResult = await postgresClient.query(
|
||||
`SELECT MAX(period_end) as latest_date, MAX(synced_at) as synced_at
|
||||
FROM engagement_snapshots WHERE period_type = $1`,
|
||||
[period]
|
||||
);
|
||||
|
||||
const latestDate = latestResult.rows[0]?.latest_date;
|
||||
const lastSynced = latestResult.rows[0]?.synced_at;
|
||||
|
||||
if (!latestDate) {
|
||||
return NextResponse.json({
|
||||
totalStaff: 0,
|
||||
activeThisPeriod: 0,
|
||||
avgHoursWorked: 0,
|
||||
avgBillableHours: 0,
|
||||
avgTeamsMeetings: 0,
|
||||
avgEmailsSent: 0,
|
||||
lastSynced: null,
|
||||
configured: isMsgraphConfigured(),
|
||||
});
|
||||
}
|
||||
|
||||
// Interval map
|
||||
const intervalMap: Record<string, string> = {
|
||||
D7: '7 days',
|
||||
D30: '30 days',
|
||||
D90: '90 days',
|
||||
};
|
||||
const interval = intervalMap[period] || '30 days';
|
||||
|
||||
// Exclude service/automation accounts: those with a snapshot showing zero inbound
|
||||
// across all channels (pure outbound senders like Autotask relay accounts)
|
||||
const notAutomatedFilter = `NOT (
|
||||
es.user_email IS NOT NULL
|
||||
AND COALESCE(es.emails_received, 0) = 0
|
||||
AND COALESCE(es.teams_chat_messages, 0) = 0
|
||||
AND COALESCE(es.teams_meetings_attended, 0) = 0
|
||||
AND COALESCE(es.teams_calls, 0) = 0
|
||||
)`;
|
||||
|
||||
// Staff count: human accounts (exclude pure-outbound service accounts)
|
||||
const staffResult = await postgresClient.query(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM graph_users gu
|
||||
JOIN (
|
||||
SELECT DISTINCT ON (LOWER(email)) id, email
|
||||
FROM resources
|
||||
WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL
|
||||
ORDER BY LOWER(email), id
|
||||
) r ON LOWER(r.email) = LOWER(gu.email)
|
||||
LEFT JOIN engagement_snapshots es
|
||||
ON LOWER(es.user_email) = LOWER(gu.email)
|
||||
AND es.period_type = $1 AND es.period_end = $2
|
||||
WHERE gu.account_enabled = true
|
||||
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
|
||||
AND LOWER(gu.email) NOT LIKE '%#ext#%'
|
||||
AND ${notAutomatedFilter}`,
|
||||
[period, latestDate]
|
||||
);
|
||||
const totalStaff = parseInt(staffResult.rows[0]?.count ?? '0');
|
||||
|
||||
// Active users (had any Teams or email activity)
|
||||
const activeResult = await postgresClient.query(
|
||||
`SELECT COUNT(DISTINCT es.user_email) as count
|
||||
FROM engagement_snapshots es
|
||||
JOIN graph_users gu ON LOWER(gu.email) = LOWER(es.user_email)
|
||||
JOIN (
|
||||
SELECT DISTINCT ON (LOWER(email)) id, email
|
||||
FROM resources
|
||||
WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL
|
||||
ORDER BY LOWER(email), id
|
||||
) r ON LOWER(r.email) = LOWER(gu.email)
|
||||
WHERE es.period_type = $1 AND es.period_end = $2
|
||||
AND (es.teams_meetings_attended > 0 OR es.teams_chat_messages > 0 OR es.emails_sent > 0)
|
||||
AND gu.account_enabled = true
|
||||
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
|
||||
AND LOWER(gu.email) NOT LIKE '%#ext#%'
|
||||
AND ${notAutomatedFilter}`,
|
||||
[period, latestDate]
|
||||
);
|
||||
const activeThisPeriod = parseInt(activeResult.rows[0]?.count ?? '0');
|
||||
|
||||
// Avg Teams meetings and emails (excluding automated senders)
|
||||
const avgResult = await postgresClient.query(
|
||||
`SELECT
|
||||
AVG(es.teams_meetings_attended) as avg_meetings,
|
||||
AVG(es.emails_sent) as avg_emails_sent
|
||||
FROM engagement_snapshots es
|
||||
JOIN graph_users gu ON LOWER(gu.email) = LOWER(es.user_email)
|
||||
JOIN (
|
||||
SELECT DISTINCT ON (LOWER(email)) id, email
|
||||
FROM resources
|
||||
WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL
|
||||
ORDER BY LOWER(email), id
|
||||
) r ON LOWER(r.email) = LOWER(gu.email)
|
||||
WHERE es.period_type = $1 AND es.period_end = $2
|
||||
AND gu.account_enabled = true
|
||||
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
|
||||
AND LOWER(gu.email) NOT LIKE '%#ext#%'
|
||||
AND ${notAutomatedFilter}`,
|
||||
[period, latestDate]
|
||||
);
|
||||
|
||||
// Avg hours from Autotask time entries joined via resources
|
||||
const hoursResult = await postgresClient.query(
|
||||
`SELECT
|
||||
AVG(resource_hours.total_hours) as avg_hours,
|
||||
AVG(resource_hours.billable_hours) as avg_billable
|
||||
FROM (
|
||||
SELECT
|
||||
r.email,
|
||||
COALESCE(SUM(te.hours_worked), 0) as total_hours,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(te.billable, true) = true THEN te.hours_worked ELSE 0 END), 0) as billable_hours
|
||||
FROM graph_users gu
|
||||
JOIN resources r ON LOWER(r.email) = LOWER(gu.email)
|
||||
AND (r.is_deleted = false OR r.is_deleted IS NULL)
|
||||
LEFT JOIN time_entries te ON te.resource_id = r.id
|
||||
AND te.entry_date >= NOW() - INTERVAL '${interval}'
|
||||
AND (te.is_deleted = false OR te.is_deleted IS NULL)
|
||||
WHERE gu.account_enabled = true
|
||||
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
|
||||
AND LOWER(gu.email) NOT LIKE '%#ext#%'
|
||||
GROUP BY r.email
|
||||
) resource_hours`
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
totalStaff,
|
||||
activeThisPeriod,
|
||||
avgHoursWorked: parseFloat(hoursResult.rows[0]?.avg_hours ?? '0').toFixed(1),
|
||||
avgBillableHours: parseFloat(hoursResult.rows[0]?.avg_billable ?? '0').toFixed(1),
|
||||
avgTeamsMeetings: parseFloat(avgResult.rows[0]?.avg_meetings ?? '0').toFixed(1),
|
||||
avgEmailsSent: parseFloat(avgResult.rows[0]?.avg_emails_sent ?? '0').toFixed(0),
|
||||
lastSynced,
|
||||
configured: isMsgraphConfigured(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ENGAGEMENT-SUMMARY] Error:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch engagement summary' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
30
app/api/engagement/sync/route.ts
Normal file
30
app/api/engagement/sync/route.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { getEngagementSyncService } from '@/lib/services/engagement-sync-service';
|
||||
import { isMsgraphConfigured } from '@/lib/services/msgraph-factory';
|
||||
|
||||
export async function POST() {
|
||||
if (!isMsgraphConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Microsoft Graph not configured. Set MSGRAPH_CLIENT_ID, MSGRAPH_CLIENT_SECRET, MSGRAPH_TENANT_ID.' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const service = getEngagementSyncService();
|
||||
|
||||
if (service.isSyncInProgress()) {
|
||||
return NextResponse.json({ error: 'Sync already in progress' }, { status: 409 });
|
||||
}
|
||||
|
||||
// Fire and forget
|
||||
service.sync().catch(err => {
|
||||
console.error('[ENGAGEMENT-SYNC-API] Background sync failed:', err);
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: 'Engagement sync started' });
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const service = getEngagementSyncService();
|
||||
return NextResponse.json({ isSyncing: service.isSyncInProgress() });
|
||||
}
|
||||
227
app/api/engagement/user/[userId]/history/route.ts
Normal file
227
app/api/engagement/user/[userId]/history/route.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
import { isZoomConfigured } from '@/lib/services/zoom-factory';
|
||||
|
||||
interface MonthData {
|
||||
month: string;
|
||||
hoursWorked: number;
|
||||
billableHours: number;
|
||||
daysWorked: number;
|
||||
teamsMessages: number;
|
||||
teamsPrivateMessages: number;
|
||||
teamsCalls: number;
|
||||
meetingsAttended: number;
|
||||
meetingsOrganized: number;
|
||||
emailsSent: number;
|
||||
emailsReceived: number;
|
||||
totalMeetings: number;
|
||||
clientMeetings: number;
|
||||
meetingDurationMinutes: number;
|
||||
zoomCalls: number;
|
||||
zoomClientCalls: number;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ userId: string }> }
|
||||
) {
|
||||
const { userId } = await params;
|
||||
|
||||
try {
|
||||
const userResult = await postgresClient.query(
|
||||
`SELECT gu.*,
|
||||
(SELECT r2.id FROM resources r2
|
||||
WHERE LOWER(r2.email) = LOWER(gu.email)
|
||||
AND (r2.is_deleted = false OR r2.is_deleted IS NULL)
|
||||
ORDER BY (SELECT MAX(te.entry_date) FROM time_entries te WHERE te.resource_id = r2.id AND (te.is_deleted = false OR te.is_deleted IS NULL)) DESC NULLS LAST
|
||||
LIMIT 1) as autotask_resource_id
|
||||
FROM graph_users gu
|
||||
WHERE gu.id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
// Daily time entries for the past 365 days
|
||||
const dailyResult = user.autotask_resource_id
|
||||
? await postgresClient.query(
|
||||
`SELECT
|
||||
TO_CHAR(entry_date, 'YYYY-MM-DD') as date,
|
||||
SUM(hours_worked) as hours_worked,
|
||||
SUM(CASE WHEN COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as billable_hours
|
||||
FROM time_entries
|
||||
WHERE resource_id = $1
|
||||
AND (is_deleted = false OR is_deleted IS NULL)
|
||||
AND entry_date >= NOW() - INTERVAL '365 days'
|
||||
GROUP BY TO_CHAR(entry_date, 'YYYY-MM-DD')
|
||||
ORDER BY date`,
|
||||
[user.autotask_resource_id]
|
||||
)
|
||||
: null;
|
||||
|
||||
// Monthly time entries for the past 12 months
|
||||
const monthlyHoursResult = user.autotask_resource_id
|
||||
? await postgresClient.query(
|
||||
`SELECT
|
||||
TO_CHAR(DATE_TRUNC('month', entry_date), 'YYYY-MM') as month,
|
||||
SUM(hours_worked) as hours_worked,
|
||||
SUM(CASE WHEN COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as billable_hours,
|
||||
COUNT(DISTINCT TO_CHAR(entry_date, 'YYYY-MM-DD')) as days_worked
|
||||
FROM time_entries
|
||||
WHERE resource_id = $1
|
||||
AND (is_deleted = false OR is_deleted IS NULL)
|
||||
AND entry_date >= DATE_TRUNC('month', NOW() - INTERVAL '11 months')
|
||||
GROUP BY DATE_TRUNC('month', entry_date)
|
||||
ORDER BY month`,
|
||||
[user.autotask_resource_id]
|
||||
)
|
||||
: null;
|
||||
|
||||
// Monthly engagement snapshots — latest D30 per calendar month
|
||||
const monthlySnapshotsResult = await postgresClient.query(
|
||||
`SELECT DISTINCT ON (TO_CHAR(period_end, 'YYYY-MM'))
|
||||
TO_CHAR(period_end, 'YYYY-MM') as month,
|
||||
teams_chat_messages,
|
||||
teams_private_messages,
|
||||
teams_calls,
|
||||
teams_meetings_attended,
|
||||
teams_meetings_organized,
|
||||
emails_sent,
|
||||
emails_received
|
||||
FROM engagement_snapshots
|
||||
WHERE LOWER(user_email) = LOWER($1)
|
||||
AND period_type = 'D30'
|
||||
AND period_end >= NOW() - INTERVAL '13 months'
|
||||
ORDER BY TO_CHAR(period_end, 'YYYY-MM'), period_end DESC`,
|
||||
[user.email]
|
||||
);
|
||||
|
||||
// Monthly Teams meetings
|
||||
let monthlyMeetingsResult = null;
|
||||
try {
|
||||
monthlyMeetingsResult = await postgresClient.query(
|
||||
`SELECT
|
||||
TO_CHAR(DATE_TRUNC('month', start_time), 'YYYY-MM') as month,
|
||||
COUNT(*) as total_meetings,
|
||||
SUM(CASE WHEN has_client_attendees THEN 1 ELSE 0 END) as client_meetings,
|
||||
SUM(COALESCE(duration_minutes, 0)) as total_duration_minutes
|
||||
FROM teams_meetings
|
||||
WHERE LOWER(user_email) = LOWER($1)
|
||||
AND start_time >= DATE_TRUNC('month', NOW() - INTERVAL '11 months')
|
||||
GROUP BY DATE_TRUNC('month', start_time)
|
||||
ORDER BY month`,
|
||||
[user.email]
|
||||
);
|
||||
} catch { /* table may not exist */ }
|
||||
|
||||
// Monthly Zoom calls
|
||||
let monthlyZoomResult = null;
|
||||
if (isZoomConfigured()) {
|
||||
try {
|
||||
monthlyZoomResult = await postgresClient.query(
|
||||
`SELECT
|
||||
TO_CHAR(DATE_TRUNC('month', start_time), 'YYYY-MM') as month,
|
||||
COUNT(*) as call_count,
|
||||
SUM(CASE WHEN matched_company_id IS NOT NULL THEN 1 ELSE 0 END) as client_calls
|
||||
FROM zoom_calls
|
||||
WHERE LOWER(resource_email) = LOWER($1)
|
||||
AND call_status = 'completed'
|
||||
AND COALESCE(duration_seconds, 0) > 0
|
||||
AND start_time >= DATE_TRUNC('month', NOW() - INTERVAL '11 months')
|
||||
GROUP BY DATE_TRUNC('month', start_time)
|
||||
ORDER BY month`,
|
||||
[user.email]
|
||||
);
|
||||
} catch { /* table may not exist */ }
|
||||
}
|
||||
|
||||
// Build a complete 12-month map
|
||||
const now = new Date();
|
||||
const monthMap = new Map<string, MonthData>();
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
monthMap.set(key, {
|
||||
month: key,
|
||||
hoursWorked: 0,
|
||||
billableHours: 0,
|
||||
daysWorked: 0,
|
||||
teamsMessages: 0,
|
||||
teamsPrivateMessages: 0,
|
||||
teamsCalls: 0,
|
||||
meetingsAttended: 0,
|
||||
meetingsOrganized: 0,
|
||||
emailsSent: 0,
|
||||
emailsReceived: 0,
|
||||
totalMeetings: 0,
|
||||
clientMeetings: 0,
|
||||
meetingDurationMinutes: 0,
|
||||
zoomCalls: 0,
|
||||
zoomClientCalls: 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of monthlyHoursResult?.rows ?? []) {
|
||||
const m = monthMap.get(row.month);
|
||||
if (m) {
|
||||
m.hoursWorked = parseFloat(row.hours_worked ?? 0);
|
||||
m.billableHours = parseFloat(row.billable_hours ?? 0);
|
||||
m.daysWorked = parseInt(row.days_worked ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of monthlySnapshotsResult.rows) {
|
||||
const m = monthMap.get(row.month);
|
||||
if (m) {
|
||||
m.teamsMessages = parseInt(row.teams_chat_messages ?? 0);
|
||||
m.teamsPrivateMessages = parseInt(row.teams_private_messages ?? 0);
|
||||
m.teamsCalls = parseInt(row.teams_calls ?? 0);
|
||||
m.meetingsAttended = parseInt(row.teams_meetings_attended ?? 0);
|
||||
m.meetingsOrganized = parseInt(row.teams_meetings_organized ?? 0);
|
||||
m.emailsSent = parseInt(row.emails_sent ?? 0);
|
||||
m.emailsReceived = parseInt(row.emails_received ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of monthlyMeetingsResult?.rows ?? []) {
|
||||
const m = monthMap.get(row.month);
|
||||
if (m) {
|
||||
m.totalMeetings = parseInt(row.total_meetings ?? 0);
|
||||
m.clientMeetings = parseInt(row.client_meetings ?? 0);
|
||||
m.meetingDurationMinutes = parseInt(row.total_duration_minutes ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of monthlyZoomResult?.rows ?? []) {
|
||||
const m = monthMap.get(row.month);
|
||||
if (m) {
|
||||
m.zoomCalls = parseInt(row.call_count ?? 0);
|
||||
m.zoomClientCalls = parseInt(row.client_calls ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
displayName: user.display_name,
|
||||
email: user.email,
|
||||
jobTitle: user.job_title,
|
||||
department: user.department,
|
||||
autotaskResourceId: user.autotask_resource_id,
|
||||
},
|
||||
daily: (dailyResult?.rows ?? []).map(r => ({
|
||||
date: r.date,
|
||||
hoursWorked: parseFloat(r.hours_worked ?? 0),
|
||||
billableHours: parseFloat(r.billable_hours ?? 0),
|
||||
})),
|
||||
monthly: Array.from(monthMap.values()),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ENGAGEMENT-HISTORY] Error:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch history' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
581
app/api/engagement/user/[userId]/route.ts
Normal file
581
app/api/engagement/user/[userId]/route.ts
Normal file
|
|
@ -0,0 +1,581 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
import { isZoomConfigured } from '@/lib/services/zoom-factory';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ userId: string }> }
|
||||
) {
|
||||
const { userId } = await params;
|
||||
const periodParam = (request.nextUrl.searchParams.get('period') ?? 'D30').toUpperCase();
|
||||
const periodDays = periodParam === 'D7' ? 7 : periodParam === 'D90' ? 90 : 30;
|
||||
|
||||
try {
|
||||
// Get user from graph_users
|
||||
const userResult = await postgresClient.query(
|
||||
`SELECT gu.*,
|
||||
(SELECT r2.id FROM resources r2
|
||||
WHERE LOWER(r2.email) = LOWER(gu.email)
|
||||
AND (r2.is_deleted = false OR r2.is_deleted IS NULL)
|
||||
ORDER BY (SELECT MAX(te.entry_date) FROM time_entries te WHERE te.resource_id = r2.id AND (te.is_deleted = false OR te.is_deleted IS NULL)) DESC NULLS LAST
|
||||
LIMIT 1) as autotask_resource_id
|
||||
FROM graph_users gu
|
||||
WHERE gu.id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
// Get all snapshots for this user across periods
|
||||
const snapshotsResult = await postgresClient.query(
|
||||
`SELECT *
|
||||
FROM engagement_snapshots
|
||||
WHERE LOWER(user_email) = LOWER($1)
|
||||
ORDER BY period_end DESC, period_type`,
|
||||
[user.email]
|
||||
);
|
||||
|
||||
// Get Autotask hours per period
|
||||
const hoursResult = user.autotask_resource_id
|
||||
? await postgresClient.query(
|
||||
`SELECT
|
||||
SUM(CASE WHEN entry_date >= NOW() - INTERVAL '7 days' THEN hours_worked ELSE 0 END) as hours_d7,
|
||||
SUM(CASE WHEN entry_date >= NOW() - INTERVAL '30 days' THEN hours_worked ELSE 0 END) as hours_d30,
|
||||
SUM(CASE WHEN entry_date >= NOW() - INTERVAL '90 days' THEN hours_worked ELSE 0 END) as hours_d90,
|
||||
SUM(CASE WHEN entry_date >= NOW() - INTERVAL '7 days' AND COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as billable_d7,
|
||||
SUM(CASE WHEN entry_date >= NOW() - INTERVAL '30 days' AND COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as billable_d30,
|
||||
SUM(CASE WHEN entry_date >= NOW() - INTERVAL '90 days' AND COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as billable_d90
|
||||
FROM time_entries
|
||||
WHERE resource_id = $1
|
||||
AND (is_deleted = false OR is_deleted IS NULL)
|
||||
AND COALESCE(type, 0) NOT IN (15, 16)
|
||||
AND COALESCE(allocation_code_id, 0) NOT IN (91206, 91209)`,
|
||||
[user.autotask_resource_id]
|
||||
)
|
||||
: null;
|
||||
|
||||
const hours = hoursResult?.rows[0];
|
||||
|
||||
// Recent time entries
|
||||
const recentEntriesResult = user.autotask_resource_id
|
||||
? await postgresClient.query(
|
||||
`SELECT te.entry_date, te.hours_worked, te.billable, te.notes, te.title,
|
||||
te.start_date_time, te.end_date_time,
|
||||
COALESCE(c.company_name, tc.company_name) as company_name
|
||||
FROM time_entries te
|
||||
LEFT JOIN companies c ON c.id = te.company_id
|
||||
LEFT JOIN tickets t ON t.id = te.ticket_id
|
||||
LEFT JOIN companies tc ON tc.id = t.company_id
|
||||
WHERE te.resource_id = $1
|
||||
AND (te.is_deleted = false OR te.is_deleted IS NULL)
|
||||
AND te.entry_date >= NOW() - ($2 || ' days')::INTERVAL
|
||||
AND COALESCE(te.type, 0) NOT IN (15, 16)
|
||||
AND COALESCE(te.allocation_code_id, 0) NOT IN (91206, 91209)
|
||||
ORDER BY te.entry_date DESC
|
||||
LIMIT 500`,
|
||||
[user.autotask_resource_id, periodDays]
|
||||
)
|
||||
: null;
|
||||
|
||||
// Teams meeting detail records (client-attended)
|
||||
let recentTeamsMeetings: Array<{
|
||||
subject: string | null;
|
||||
startTime: string;
|
||||
durationMinutes: number | null;
|
||||
attendeeCount: number;
|
||||
clientAttendeeCount: number;
|
||||
hasClientAttendees: boolean;
|
||||
clientCompanies: string[];
|
||||
participantNames: string[];
|
||||
}> = [];
|
||||
try {
|
||||
const teamsMeetingsResult = await postgresClient.query(
|
||||
`SELECT tm.subject, tm.start_time, tm.duration_minutes,
|
||||
tm.attendee_count, tm.client_attendee_count, tm.has_client_attendees,
|
||||
ARRAY_REMOVE(ARRAY_AGG(DISTINCT co.company_name), NULL) AS client_companies,
|
||||
ARRAY_REMOVE(ARRAY_AGG(DISTINCT COALESCE(tma.attendee_name, tma.attendee_email)), NULL) AS participant_names
|
||||
FROM teams_meetings tm
|
||||
LEFT JOIN teams_meeting_attendees tma ON tma.meeting_id = tm.id
|
||||
LEFT JOIN companies co ON co.id = tma.matched_company_id
|
||||
WHERE LOWER(tm.user_email) = LOWER($1)
|
||||
AND tm.start_time >= NOW() - ($2 || ' days')::INTERVAL
|
||||
GROUP BY tm.id
|
||||
ORDER BY tm.start_time DESC
|
||||
LIMIT 200`,
|
||||
[user.email, periodDays]
|
||||
);
|
||||
recentTeamsMeetings = teamsMeetingsResult.rows.map(r => ({
|
||||
subject: r.subject,
|
||||
startTime: r.start_time,
|
||||
durationMinutes: r.duration_minutes,
|
||||
attendeeCount: r.attendee_count,
|
||||
clientAttendeeCount: r.client_attendee_count,
|
||||
hasClientAttendees: r.has_client_attendees,
|
||||
clientCompanies: r.client_companies ?? [],
|
||||
participantNames: r.participant_names ?? [],
|
||||
}));
|
||||
} catch {
|
||||
// teams_meetings table may not exist yet
|
||||
}
|
||||
|
||||
// Peer max benchmarks — highest value across all active employees for this period
|
||||
const peerMaxResult = await postgresClient.query(
|
||||
`SELECT
|
||||
MAX(h.hours_total) as max_hours,
|
||||
MAX(h.hours_billable) as max_billable_hours,
|
||||
MAX(m.meeting_count) as max_meetings,
|
||||
MAX(m.client_meetings)as max_client_meetings,
|
||||
MAX(s.messages) as max_messages,
|
||||
MAX(s.emails) as max_emails,
|
||||
MAX(zc.calls) as max_calls
|
||||
FROM (
|
||||
SELECT resource_id,
|
||||
SUM(hours_worked) as hours_total,
|
||||
SUM(CASE WHEN COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as hours_billable
|
||||
FROM time_entries
|
||||
WHERE (is_deleted = false OR is_deleted IS NULL)
|
||||
AND entry_date >= NOW() - ($1 || ' days')::INTERVAL
|
||||
AND COALESCE(type, 0) NOT IN (15, 16)
|
||||
AND COALESCE(allocation_code_id, 0) NOT IN (91206, 91209)
|
||||
GROUP BY resource_id
|
||||
) h
|
||||
CROSS JOIN (
|
||||
SELECT user_email,
|
||||
COUNT(*) as meeting_count,
|
||||
SUM(CASE WHEN has_client_attendees THEN 1 ELSE 0 END) as client_meetings
|
||||
FROM teams_meetings
|
||||
WHERE start_time >= NOW() - ($1 || ' days')::INTERVAL
|
||||
GROUP BY user_email
|
||||
) m
|
||||
CROSS JOIN (
|
||||
SELECT user_email,
|
||||
MAX(teams_chat_messages + teams_private_messages) as messages,
|
||||
MAX(emails_sent) as emails
|
||||
FROM engagement_snapshots
|
||||
WHERE period_type = $2
|
||||
GROUP BY user_email
|
||||
) s
|
||||
CROSS JOIN (
|
||||
SELECT resource_email,
|
||||
COUNT(*) as calls
|
||||
FROM zoom_calls
|
||||
WHERE call_status = 'completed'
|
||||
AND start_time >= NOW() - ($1 || ' days')::INTERVAL
|
||||
GROUP BY resource_email
|
||||
) zc`,
|
||||
[periodDays, periodParam]
|
||||
).catch(() => null);
|
||||
|
||||
// Previous period values for trend calculation
|
||||
const prevPeriodResult = user.autotask_resource_id
|
||||
? await postgresClient.query(
|
||||
`SELECT
|
||||
SUM(hours_worked) as prev_hours,
|
||||
SUM(CASE WHEN COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as prev_billable
|
||||
FROM time_entries
|
||||
WHERE resource_id = $1
|
||||
AND (is_deleted = false OR is_deleted IS NULL)
|
||||
AND entry_date >= NOW() - ($2 || ' days')::INTERVAL * 2
|
||||
AND entry_date < NOW() - ($2 || ' days')::INTERVAL
|
||||
AND COALESCE(type, 0) NOT IN (15, 16)
|
||||
AND COALESCE(allocation_code_id, 0) NOT IN (91206, 91209)`,
|
||||
[user.autotask_resource_id, periodDays]
|
||||
).catch(() => null)
|
||||
: null;
|
||||
|
||||
const prevMeetingsResult = await postgresClient.query(
|
||||
`SELECT COUNT(*) as prev_meetings,
|
||||
SUM(CASE WHEN has_client_attendees THEN 1 ELSE 0 END) as prev_client_meetings
|
||||
FROM teams_meetings
|
||||
WHERE LOWER(user_email) = LOWER($1)
|
||||
AND start_time >= NOW() - ($2 || ' days')::INTERVAL * 2
|
||||
AND start_time < NOW() - ($2 || ' days')::INTERVAL`,
|
||||
[user.email, periodDays]
|
||||
).catch(() => null);
|
||||
|
||||
const prevZoomResult = await postgresClient.query(
|
||||
`SELECT COUNT(*) as prev_calls
|
||||
FROM zoom_calls
|
||||
WHERE LOWER(resource_email) = LOWER($1)
|
||||
AND call_status = 'completed'
|
||||
AND start_time >= NOW() - ($2 || ' days')::INTERVAL * 2
|
||||
AND start_time < NOW() - ($2 || ' days')::INTERVAL`,
|
||||
[user.email, periodDays]
|
||||
).catch(() => null);
|
||||
|
||||
// After-hours meetings (5:30 PM – 7:00 AM America/New_York)
|
||||
const afterHoursMeetingsResult = await postgresClient.query(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM teams_meetings
|
||||
WHERE LOWER(user_email) = LOWER($1)
|
||||
AND start_time >= NOW() - ($2 || ' days')::INTERVAL
|
||||
AND (
|
||||
EXTRACT(HOUR FROM start_time AT TIME ZONE 'America/New_York') * 60
|
||||
+ EXTRACT(MINUTE FROM start_time AT TIME ZONE 'America/New_York') >= 1050
|
||||
OR
|
||||
EXTRACT(HOUR FROM start_time AT TIME ZONE 'America/New_York') * 60
|
||||
+ EXTRACT(MINUTE FROM start_time AT TIME ZONE 'America/New_York') < 420
|
||||
)`,
|
||||
[user.email, periodDays]
|
||||
).catch(() => ({ rows: [{ count: 0 }] }));
|
||||
const afterHoursMeetings = parseInt(afterHoursMeetingsResult.rows[0]?.count ?? 0);
|
||||
|
||||
// Daily activity heatmap data
|
||||
let dailyActivity: Array<{ date: string; meetings: number; zoomCalls: number; hours: number; meetingMins: number }> = [];
|
||||
try {
|
||||
const dailyResult = await postgresClient.query(
|
||||
`SELECT
|
||||
day::date as date,
|
||||
COALESCE(SUM(meetings), 0)::int as meetings,
|
||||
COALESCE(SUM(zoom_calls), 0)::int as zoom_calls,
|
||||
COALESCE(SUM(hours), 0)::float as hours,
|
||||
COALESCE(SUM(meeting_mins), 0)::int as meeting_mins
|
||||
FROM (
|
||||
SELECT DATE(start_time) as day, COUNT(*) as meetings, SUM(duration_minutes) as meeting_mins, 0 as zoom_calls, 0 as hours
|
||||
FROM teams_meetings
|
||||
WHERE LOWER(user_email) = LOWER($1)
|
||||
AND start_time >= NOW() - ($2 || ' days')::INTERVAL
|
||||
GROUP BY DATE(start_time)
|
||||
UNION ALL
|
||||
SELECT DATE(start_time) as day, 0, 0, COUNT(*) as zoom_calls, 0
|
||||
FROM zoom_calls
|
||||
WHERE LOWER(resource_email) = LOWER($1)
|
||||
AND call_status = 'completed'
|
||||
AND start_time >= NOW() - ($2 || ' days')::INTERVAL
|
||||
GROUP BY DATE(start_time)
|
||||
UNION ALL
|
||||
SELECT DATE(entry_date) as day, 0, 0, 0, SUM(hours_worked) as hours
|
||||
FROM time_entries te
|
||||
WHERE te.resource_id = $3
|
||||
AND (te.is_deleted = false OR te.is_deleted IS NULL)
|
||||
AND entry_date >= NOW() - ($2 || ' days')::INTERVAL
|
||||
AND COALESCE(te.type, 0) NOT IN (15, 16)
|
||||
AND COALESCE(te.allocation_code_id, 0) NOT IN (91206, 91209)
|
||||
GROUP BY DATE(entry_date)
|
||||
) combined
|
||||
GROUP BY day
|
||||
ORDER BY day`,
|
||||
[user.email, periodDays, user.autotask_resource_id]
|
||||
);
|
||||
dailyActivity = dailyResult.rows.map(r => ({
|
||||
date: r.date instanceof Date ? r.date.toISOString().slice(0, 10) : String(r.date).slice(0, 10),
|
||||
meetings: Number(r.meetings),
|
||||
zoomCalls: Number(r.zoom_calls),
|
||||
hours: Number(r.hours),
|
||||
meetingMins: Number(r.meeting_mins),
|
||||
}));
|
||||
} catch {
|
||||
// ignore if tables missing
|
||||
}
|
||||
|
||||
// Zoom data (only if configured and tables exist)
|
||||
let zoomData = null;
|
||||
if (isZoomConfigured()) {
|
||||
try {
|
||||
const email = user.email;
|
||||
|
||||
const [zoomCallsResult, zoomMeetingsResult, zoomTopClientsResult, recentCallsResult, recentMeetingsResult] = await Promise.all([
|
||||
postgresClient.query(
|
||||
`SELECT
|
||||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' THEN 1 ELSE 0 END) as calls_d7,
|
||||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' THEN 1 ELSE 0 END) as calls_d30,
|
||||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' THEN 1 ELSE 0 END) as calls_d90,
|
||||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' AND (matched_contact_id IS NOT NULL OR matched_company_id IS NOT NULL) THEN 1 ELSE 0 END) as client_calls_d7,
|
||||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' AND (matched_contact_id IS NOT NULL OR matched_company_id IS NOT NULL) THEN 1 ELSE 0 END) as client_calls_d30,
|
||||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' AND (matched_contact_id IS NOT NULL OR matched_company_id IS NOT NULL) THEN 1 ELSE 0 END) as client_calls_d90,
|
||||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' AND direction = 'outbound' THEN 1 ELSE 0 END) as outbound_d7,
|
||||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' AND direction = 'outbound' THEN 1 ELSE 0 END) as outbound_d30,
|
||||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' AND direction = 'outbound' THEN 1 ELSE 0 END) as outbound_d90,
|
||||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' AND direction = 'inbound' THEN 1 ELSE 0 END) as inbound_d7,
|
||||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' AND direction = 'inbound' THEN 1 ELSE 0 END) as inbound_d30,
|
||||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' AND direction = 'inbound' THEN 1 ELSE 0 END) as inbound_d90,
|
||||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' THEN COALESCE(duration_seconds, 0) ELSE 0 END) as duration_d7,
|
||||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' THEN COALESCE(duration_seconds, 0) ELSE 0 END) as duration_d30,
|
||||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' THEN COALESCE(duration_seconds, 0) ELSE 0 END) as duration_d90
|
||||
FROM zoom_calls
|
||||
WHERE LOWER(resource_email) = LOWER($1)
|
||||
AND call_status = 'completed'
|
||||
AND COALESCE(duration_seconds, 0) > 0`,
|
||||
[email]
|
||||
),
|
||||
postgresClient.query(
|
||||
`SELECT
|
||||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' THEN 1 ELSE 0 END) as meetings_d7,
|
||||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' THEN 1 ELSE 0 END) as meetings_d30,
|
||||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' THEN 1 ELSE 0 END) as meetings_d90,
|
||||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' AND has_client_attendees = true THEN 1 ELSE 0 END) as client_meetings_d7,
|
||||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' AND has_client_attendees = true THEN 1 ELSE 0 END) as client_meetings_d30,
|
||||
SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' AND has_client_attendees = true THEN 1 ELSE 0 END) as client_meetings_d90
|
||||
FROM zoom_meetings
|
||||
WHERE LOWER(host_email) = LOWER($1)`,
|
||||
[email]
|
||||
),
|
||||
postgresClient.query(
|
||||
`SELECT
|
||||
co.company_name,
|
||||
COUNT(DISTINCT zc.id) as call_count,
|
||||
COUNT(DISTINCT zm.id) as meeting_count
|
||||
FROM companies co
|
||||
LEFT JOIN zoom_calls zc
|
||||
ON zc.matched_company_id = co.id
|
||||
AND LOWER(zc.resource_email) = LOWER($1)
|
||||
AND zc.start_time >= NOW() - INTERVAL '30 days'
|
||||
LEFT JOIN zoom_meetings zm
|
||||
ON zm.id IN (
|
||||
SELECT zmp.meeting_id FROM zoom_meeting_participants zmp
|
||||
WHERE zmp.matched_company_id = co.id
|
||||
)
|
||||
AND LOWER(zm.host_email) = LOWER($1)
|
||||
AND zm.start_time >= NOW() - INTERVAL '30 days'
|
||||
WHERE (zc.id IS NOT NULL OR zm.id IS NOT NULL)
|
||||
GROUP BY co.id, co.company_name
|
||||
ORDER BY (COUNT(DISTINCT zc.id) + COUNT(DISTINCT zm.id)) DESC
|
||||
LIMIT 5`,
|
||||
[email]
|
||||
),
|
||||
postgresClient.query(
|
||||
`SELECT zc.direction, zc.call_status, zc.other_party_name, zc.other_party_number,
|
||||
zc.start_time, zc.duration_seconds,
|
||||
co.company_name
|
||||
FROM zoom_calls zc
|
||||
LEFT JOIN companies co ON co.id = zc.matched_company_id
|
||||
WHERE LOWER(zc.resource_email) = LOWER($1)
|
||||
AND zc.call_status = 'completed'
|
||||
AND COALESCE(zc.duration_seconds, 0) > 0
|
||||
ORDER BY zc.start_time DESC
|
||||
LIMIT 30`,
|
||||
[email]
|
||||
),
|
||||
postgresClient.query(
|
||||
`SELECT zm.id, zm.topic, zm.start_time, zm.end_time, zm.duration_minutes,
|
||||
zm.participant_count, zm.client_participant_count, zm.has_client_attendees,
|
||||
ARRAY_REMOVE(ARRAY_AGG(DISTINCT CASE WHEN NOT zmp.is_internal AND zmp.participant_name IS NOT NULL THEN zmp.participant_name ELSE NULL END), NULL) AS external_participant_names,
|
||||
ARRAY_REMOVE(ARRAY_AGG(DISTINCT co.company_name), NULL) AS client_companies
|
||||
FROM zoom_meetings zm
|
||||
LEFT JOIN zoom_meeting_participants zmp ON zmp.meeting_id = zm.id
|
||||
LEFT JOIN companies co ON co.id = zmp.matched_company_id
|
||||
WHERE (LOWER(zm.host_email) = LOWER($1)
|
||||
OR EXISTS (SELECT 1 FROM zoom_meeting_participants p WHERE p.meeting_id = zm.id AND LOWER(p.participant_email) = LOWER($1)))
|
||||
AND zm.start_time >= NOW() - ($2 || ' days')::INTERVAL
|
||||
GROUP BY zm.id
|
||||
ORDER BY zm.start_time DESC
|
||||
LIMIT 100`,
|
||||
[email, periodDays]
|
||||
),
|
||||
]);
|
||||
|
||||
const cr = zoomCallsResult.rows[0];
|
||||
const mr = zoomMeetingsResult.rows[0];
|
||||
|
||||
zoomData = {
|
||||
calls: {
|
||||
d7: {
|
||||
total: parseInt(cr.calls_d7 ?? 0),
|
||||
client: parseInt(cr.client_calls_d7 ?? 0),
|
||||
outbound: parseInt(cr.outbound_d7 ?? 0),
|
||||
inbound: parseInt(cr.inbound_d7 ?? 0),
|
||||
durationSeconds: parseInt(cr.duration_d7 ?? 0),
|
||||
},
|
||||
d30: {
|
||||
total: parseInt(cr.calls_d30 ?? 0),
|
||||
client: parseInt(cr.client_calls_d30 ?? 0),
|
||||
outbound: parseInt(cr.outbound_d30 ?? 0),
|
||||
inbound: parseInt(cr.inbound_d30 ?? 0),
|
||||
durationSeconds: parseInt(cr.duration_d30 ?? 0),
|
||||
},
|
||||
d90: {
|
||||
total: parseInt(cr.calls_d90 ?? 0),
|
||||
client: parseInt(cr.client_calls_d90 ?? 0),
|
||||
outbound: parseInt(cr.outbound_d90 ?? 0),
|
||||
inbound: parseInt(cr.inbound_d90 ?? 0),
|
||||
durationSeconds: parseInt(cr.duration_d90 ?? 0),
|
||||
},
|
||||
},
|
||||
meetings: {
|
||||
d7: { total: parseInt(mr.meetings_d7 ?? 0), withClients: parseInt(mr.client_meetings_d7 ?? 0) },
|
||||
d30: { total: parseInt(mr.meetings_d30 ?? 0), withClients: parseInt(mr.client_meetings_d30 ?? 0) },
|
||||
d90: { total: parseInt(mr.meetings_d90 ?? 0), withClients: parseInt(mr.client_meetings_d90 ?? 0) },
|
||||
},
|
||||
topClients: zoomTopClientsResult.rows.map(r => ({
|
||||
companyName: r.company_name,
|
||||
callCount: parseInt(r.call_count),
|
||||
meetingCount: parseInt(r.meeting_count),
|
||||
})),
|
||||
recentCalls: recentCallsResult.rows.map(r => ({
|
||||
direction: r.direction,
|
||||
status: r.call_status,
|
||||
otherPartyName: r.other_party_name,
|
||||
otherPartyNumber: r.other_party_number,
|
||||
startTime: r.start_time,
|
||||
durationSeconds: r.duration_seconds,
|
||||
companyName: r.company_name,
|
||||
})),
|
||||
recentMeetings: recentMeetingsResult.rows.map(r => {
|
||||
const zmStart = new Date(r.start_time).getTime();
|
||||
const zmEnd = r.end_time
|
||||
? new Date(r.end_time).getTime()
|
||||
: r.duration_minutes
|
||||
? zmStart + r.duration_minutes * 60_000
|
||||
: zmStart + 60 * 60_000;
|
||||
const zmClientNames: string[] = (r.client_companies ?? []).map((n: string) => n.toLowerCase());
|
||||
const toMs = (ts: string | null): number | null => {
|
||||
if (!ts) return null;
|
||||
const s = ts.toString();
|
||||
const normalized = /[Z+\-]\d*$/.test(s.trim()) ? s : s.trim() + 'Z';
|
||||
return new Date(normalized).getTime();
|
||||
};
|
||||
const matched = (recentEntriesResult?.rows ?? []).filter(te => {
|
||||
if (r.has_client_attendees && zmClientNames.length > 0) {
|
||||
if (!te.company_name) return false;
|
||||
const teCo = te.company_name.toLowerCase();
|
||||
if (!zmClientNames.some((c: string) => teCo.includes(c) || c.includes(teCo))) return false;
|
||||
}
|
||||
if (te.start_date_time) {
|
||||
const teStart = toMs(te.start_date_time)!;
|
||||
const teEnd = te.end_date_time
|
||||
? toMs(te.end_date_time)!
|
||||
: teStart + te.hours_worked * 3_600_000;
|
||||
const tolerance = 30 * 60_000;
|
||||
return teStart < zmEnd + tolerance && teEnd > zmStart - tolerance;
|
||||
}
|
||||
const teDate = new Date((toMs(te.entry_date) ?? 0)).toUTCString().slice(0, 16);
|
||||
const zmDate = new Date(r.start_time).toUTCString().slice(0, 16);
|
||||
return teDate === zmDate;
|
||||
});
|
||||
return {
|
||||
topic: r.topic,
|
||||
startTime: r.start_time,
|
||||
endTime: r.end_time,
|
||||
durationMinutes: r.duration_minutes,
|
||||
participantCount: r.participant_count,
|
||||
clientParticipantCount: r.client_participant_count,
|
||||
hasClientAttendees: r.has_client_attendees,
|
||||
externalParticipantNames: r.external_participant_names ?? [],
|
||||
clientCompanies: r.client_companies ?? [],
|
||||
matchedEntries: matched.map(te => ({
|
||||
hours_worked: te.hours_worked,
|
||||
billable: te.billable,
|
||||
notes: te.notes,
|
||||
title: te.title,
|
||||
company_name: te.company_name,
|
||||
start_date_time: te.start_date_time,
|
||||
end_date_time: te.end_date_time,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
} catch {
|
||||
// Zoom tables may not exist yet — return null
|
||||
zoomData = null;
|
||||
}
|
||||
}
|
||||
|
||||
// After-hours summary (messages from snapshot for current period, meetings from DB)
|
||||
const currentSnap = snapshotsResult.rows.find(s => s.period_type === periodParam);
|
||||
const totalMessages = (currentSnap?.teams_chat_messages ?? 0) + (currentSnap?.teams_private_messages ?? 0);
|
||||
const totalMeetings = recentTeamsMeetings.length;
|
||||
const afterHoursMessages = currentSnap?.after_hours_messages ?? 0;
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
displayName: user.display_name,
|
||||
email: user.email,
|
||||
jobTitle: user.job_title,
|
||||
department: user.department,
|
||||
accountEnabled: user.account_enabled,
|
||||
autotaskResourceId: user.autotask_resource_id,
|
||||
},
|
||||
afterHours: {
|
||||
messages: afterHoursMessages,
|
||||
meetings: afterHoursMeetings,
|
||||
messagesPct: totalMessages > 0 ? Math.round((afterHoursMessages / totalMessages) * 100) : 0,
|
||||
meetingsPct: totalMeetings > 0 ? Math.round((afterHoursMeetings / totalMeetings) * 100) : 0,
|
||||
},
|
||||
snapshots: snapshotsResult.rows,
|
||||
hours: hours
|
||||
? {
|
||||
d7: { total: parseFloat(hours.hours_d7 ?? 0), billable: parseFloat(hours.billable_d7 ?? 0) },
|
||||
d30: { total: parseFloat(hours.hours_d30 ?? 0), billable: parseFloat(hours.billable_d30 ?? 0) },
|
||||
d90: { total: parseFloat(hours.hours_d90 ?? 0), billable: parseFloat(hours.billable_d90 ?? 0) },
|
||||
}
|
||||
: null,
|
||||
recentEntries: recentEntriesResult?.rows ?? [],
|
||||
recentTeamsMeetings: recentTeamsMeetings.map(mtg => {
|
||||
const mtgStart = new Date(mtg.startTime).getTime();
|
||||
const mtgEnd = mtg.durationMinutes
|
||||
? mtgStart + mtg.durationMinutes * 60_000
|
||||
: mtgStart + 60 * 60_000; // assume 1h if unknown
|
||||
const mtgClientNames = mtg.clientCompanies.map((n: string) => n.toLowerCase());
|
||||
// Normalize a DB timestamp to ms — treat naive timestamps as UTC
|
||||
const toMs = (ts: string | null): number | null => {
|
||||
if (!ts) return null;
|
||||
const s = ts.toString();
|
||||
// If no timezone info, append Z so Date parses it as UTC
|
||||
const normalized = /[Z+\-]\d*$/.test(s.trim()) ? s : s.trim() + 'Z';
|
||||
return new Date(normalized).getTime();
|
||||
};
|
||||
const matched = (recentEntriesResult?.rows ?? []).filter(te => {
|
||||
// Company match: if the meeting has client companies, the time entry must be for one of them
|
||||
if (mtg.hasClientAttendees && mtgClientNames.length > 0) {
|
||||
if (!te.company_name) return false;
|
||||
const teCo = te.company_name.toLowerCase();
|
||||
if (!mtgClientNames.some((c: string) => teCo.includes(c) || c.includes(teCo))) return false;
|
||||
}
|
||||
// Time match
|
||||
if (te.start_date_time) {
|
||||
const teStart = toMs(te.start_date_time)!;
|
||||
const teEnd = te.end_date_time
|
||||
? toMs(te.end_date_time)!
|
||||
: teStart + te.hours_worked * 3_600_000;
|
||||
const tolerance = 30 * 60_000;
|
||||
return teStart < mtgEnd + tolerance && teEnd > mtgStart - tolerance;
|
||||
}
|
||||
// Fallback: same UTC date
|
||||
const teDate = new Date((toMs(te.entry_date) ?? 0)).toUTCString().slice(0, 16);
|
||||
const mtgDate = new Date(mtg.startTime).toUTCString().slice(0, 16);
|
||||
return teDate === mtgDate;
|
||||
});
|
||||
return { ...mtg, matchedEntries: matched.map(te => ({
|
||||
hours_worked: te.hours_worked,
|
||||
billable: te.billable,
|
||||
notes: te.notes,
|
||||
title: te.title,
|
||||
company_name: te.company_name,
|
||||
start_date_time: te.start_date_time,
|
||||
end_date_time: te.end_date_time,
|
||||
})) };
|
||||
}),
|
||||
meetingCounts: {
|
||||
total: recentTeamsMeetings.length,
|
||||
withClients: recentTeamsMeetings.filter(m => m.hasClientAttendees).length,
|
||||
},
|
||||
dailyActivity,
|
||||
zoom: zoomData,
|
||||
peerMax: peerMaxResult?.rows[0]
|
||||
? {
|
||||
hours: parseFloat(peerMaxResult.rows[0].max_hours ?? 0),
|
||||
billableHours:parseFloat(peerMaxResult.rows[0].max_billable_hours ?? 0),
|
||||
meetings: parseInt(peerMaxResult.rows[0].max_meetings ?? 0),
|
||||
clientMeetings:parseInt(peerMaxResult.rows[0].max_client_meetings ?? 0),
|
||||
messages: parseInt(peerMaxResult.rows[0].max_messages ?? 0),
|
||||
emails: parseInt(peerMaxResult.rows[0].max_emails ?? 0),
|
||||
calls: parseInt(peerMaxResult.rows[0].max_calls ?? 0),
|
||||
}
|
||||
: null,
|
||||
trend: {
|
||||
hours: parseFloat(prevPeriodResult?.rows[0]?.prev_hours ?? 0),
|
||||
billable: parseFloat(prevPeriodResult?.rows[0]?.prev_billable ?? 0),
|
||||
meetings: parseInt(prevMeetingsResult?.rows[0]?.prev_meetings ?? 0),
|
||||
calls: parseInt(prevZoomResult?.rows[0]?.prev_calls ?? 0),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ENGAGEMENT-USER-DETAIL] Error:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch user detail' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
191
app/api/engagement/users/route.ts
Normal file
191
app/api/engagement/users/route.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const period = searchParams.get('period') || 'D30';
|
||||
const sort = searchParams.get('sort') || 'billable_hours';
|
||||
const order = searchParams.get('order') === 'asc' ? 'ASC' : 'DESC';
|
||||
const page = Math.max(1, parseInt(searchParams.get('page') || '1'));
|
||||
const pageSize = 50;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const intervalMap: Record<string, string> = {
|
||||
D7: '7 days',
|
||||
D30: '30 days',
|
||||
D90: '90 days',
|
||||
};
|
||||
const interval = intervalMap[period] || '30 days';
|
||||
|
||||
const allowedSorts: Record<string, string> = {
|
||||
hours_worked: 'hours_worked',
|
||||
billable_hours: 'billable_hours',
|
||||
teams_meetings_attended: 'teams_meetings_attended',
|
||||
teams_chat_messages: 'teams_chat_messages',
|
||||
emails_sent: 'emails_sent',
|
||||
last_activity: 'last_active',
|
||||
display_name: 'display_name',
|
||||
zoom_call_count: 'zoom_call_count',
|
||||
zoom_meeting_count: 'zoom_meeting_count',
|
||||
};
|
||||
const sortCol = allowedSorts[sort] || 'billable_hours';
|
||||
|
||||
try {
|
||||
const latestResult = await postgresClient.query(
|
||||
`SELECT MAX(period_end) as latest_date FROM engagement_snapshots WHERE period_type = $1`,
|
||||
[period]
|
||||
);
|
||||
const latestDate = latestResult.rows[0]?.latest_date;
|
||||
|
||||
if (!latestDate) {
|
||||
return NextResponse.json({ users: [], pagination: { total: 0, page, pageSize } });
|
||||
}
|
||||
|
||||
const countResult = await postgresClient.query(
|
||||
`SELECT COUNT(*) as total
|
||||
FROM graph_users gu
|
||||
JOIN (
|
||||
SELECT DISTINCT ON (LOWER(email)) id, email
|
||||
FROM resources
|
||||
WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL
|
||||
ORDER BY LOWER(email), id
|
||||
) r ON LOWER(r.email) = LOWER(gu.email)
|
||||
LEFT JOIN engagement_snapshots es_cnt
|
||||
ON LOWER(es_cnt.user_email) = LOWER(gu.email)
|
||||
AND es_cnt.period_type = $1 AND es_cnt.period_end = $2
|
||||
WHERE gu.account_enabled = true
|
||||
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
|
||||
AND LOWER(gu.email) NOT LIKE '%#ext#%'
|
||||
AND NOT (
|
||||
es_cnt.user_email IS NOT NULL
|
||||
AND COALESCE(es_cnt.emails_received, 0) = 0
|
||||
AND COALESCE(es_cnt.teams_chat_messages, 0) = 0
|
||||
AND COALESCE(es_cnt.teams_meetings_attended, 0) = 0
|
||||
AND COALESCE(es_cnt.teams_calls, 0) = 0
|
||||
)`,
|
||||
[period, latestDate]
|
||||
);
|
||||
const total = parseInt(countResult.rows[0]?.total ?? '0');
|
||||
|
||||
const usersResult = await postgresClient.query(
|
||||
`SELECT
|
||||
gu.id as graph_user_id,
|
||||
gu.display_name,
|
||||
gu.email,
|
||||
gu.job_title,
|
||||
gu.department,
|
||||
r.id as autotask_resource_id,
|
||||
COALESCE(te_agg.total_hours, 0) as hours_worked,
|
||||
COALESCE(te_agg.billable_hours, 0) as billable_hours,
|
||||
COALESCE(es.teams_chat_messages, 0) as teams_chat_messages,
|
||||
COALESCE(es.teams_private_messages, 0) as teams_private_messages,
|
||||
COALESCE(es.teams_calls, 0) as teams_calls,
|
||||
COALESCE(es.teams_meetings_attended, 0) as teams_meetings_attended,
|
||||
COALESCE(es.teams_meetings_organized, 0) as teams_meetings_organized,
|
||||
COALESCE(es.emails_sent, 0) as emails_sent,
|
||||
COALESCE(es.emails_received, 0) as emails_received,
|
||||
COALESCE(es.emails_read, 0) as emails_read,
|
||||
COALESCE(es.audio_duration_seconds, 0) as audio_duration_seconds,
|
||||
COALESCE(es.meeting_duration_seconds, 0) as meeting_duration_seconds,
|
||||
COALESCE(es.meetings_with_external, 0) as meetings_with_external,
|
||||
LEAST(GREATEST(es.last_activity_date, last_te.entry_date::date), CURRENT_DATE) as last_active,
|
||||
COALESCE(zc.zoom_call_count, 0) as zoom_call_count,
|
||||
COALESCE(zc.zoom_client_call_count, 0) as zoom_client_call_count,
|
||||
COALESCE(zc.zoom_call_duration_seconds, 0) as zoom_call_duration_seconds,
|
||||
COALESCE(zm.zoom_meeting_count, 0) as zoom_meeting_count,
|
||||
COALESCE(zm.zoom_client_meeting_count, 0) as zoom_client_meeting_count
|
||||
FROM graph_users gu
|
||||
LEFT JOIN engagement_snapshots es
|
||||
ON LOWER(es.user_email) = LOWER(gu.email)
|
||||
AND es.period_type = $1
|
||||
AND es.period_end = $2
|
||||
JOIN (
|
||||
SELECT DISTINCT ON (LOWER(email)) *
|
||||
FROM resources
|
||||
WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL
|
||||
ORDER BY LOWER(email), id
|
||||
) r ON LOWER(r.email) = LOWER(gu.email)
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COALESCE(SUM(te.hours_worked), 0) as total_hours,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(te.billable, true) = true THEN te.hours_worked ELSE 0 END), 0) as billable_hours
|
||||
FROM time_entries te
|
||||
WHERE te.resource_id = r.id
|
||||
AND te.entry_date >= NOW() - INTERVAL '${interval}'
|
||||
AND (te.is_deleted = false OR te.is_deleted IS NULL)
|
||||
) te_agg ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MAX(te2.entry_date) as entry_date
|
||||
FROM time_entries te2
|
||||
WHERE te2.resource_id = r.id
|
||||
AND (te2.is_deleted = false OR te2.is_deleted IS NULL)
|
||||
) last_te ON true
|
||||
LEFT JOIN (
|
||||
SELECT resource_email,
|
||||
COUNT(*) AS zoom_call_count,
|
||||
COUNT(*) FILTER (WHERE matched_contact_id IS NOT NULL OR matched_company_id IS NOT NULL) AS zoom_client_call_count,
|
||||
COALESCE(SUM(duration_seconds), 0) AS zoom_call_duration_seconds
|
||||
FROM zoom_calls
|
||||
WHERE start_time >= NOW() - INTERVAL '${interval}'
|
||||
AND call_status = 'completed'
|
||||
AND COALESCE(duration_seconds, 0) > 0
|
||||
GROUP BY resource_email
|
||||
) zc ON LOWER(r.email) = LOWER(zc.resource_email)
|
||||
LEFT JOIN (
|
||||
SELECT host_email,
|
||||
COUNT(*) AS zoom_meeting_count,
|
||||
COUNT(*) FILTER (WHERE has_client_attendees = true) AS zoom_client_meeting_count
|
||||
FROM zoom_meetings
|
||||
WHERE start_time >= NOW() - INTERVAL '${interval}'
|
||||
GROUP BY host_email
|
||||
) zm ON LOWER(r.email) = LOWER(zm.host_email)
|
||||
WHERE gu.account_enabled = true
|
||||
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
|
||||
AND LOWER(gu.email) NOT LIKE '%#ext#%'
|
||||
AND NOT (
|
||||
es.user_email IS NOT NULL
|
||||
AND COALESCE(es.emails_received, 0) = 0
|
||||
AND COALESCE(es.teams_chat_messages, 0) = 0
|
||||
AND COALESCE(es.teams_meetings_attended, 0) = 0
|
||||
AND COALESCE(es.teams_calls, 0) = 0
|
||||
)
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST
|
||||
LIMIT $3 OFFSET $4`,
|
||||
[period, latestDate, pageSize, offset]
|
||||
);
|
||||
|
||||
const users = usersResult.rows.map(row => ({
|
||||
graphUserId: row.graph_user_id,
|
||||
displayName: row.display_name,
|
||||
email: row.email,
|
||||
jobTitle: row.job_title,
|
||||
department: row.department,
|
||||
autotaskResourceId: row.autotask_resource_id,
|
||||
hoursWorked: parseFloat(row.hours_worked),
|
||||
billableHours: parseFloat(row.billable_hours),
|
||||
teamsMessages: parseInt(row.teams_chat_messages) + parseInt(row.teams_private_messages),
|
||||
teamsCallCount: parseInt(row.teams_calls),
|
||||
meetingsAttended: parseInt(row.teams_meetings_attended),
|
||||
meetingsOrganized: parseInt(row.teams_meetings_organized),
|
||||
emailsSent: parseInt(row.emails_sent),
|
||||
emailsReceived: parseInt(row.emails_received),
|
||||
audioDurationSeconds: parseInt(row.audio_duration_seconds),
|
||||
meetingDurationSeconds: parseInt(row.meeting_duration_seconds),
|
||||
meetingsWithExternal: parseInt(row.meetings_with_external),
|
||||
lastActivity: row.last_active,
|
||||
zoomCallCount: parseInt(row.zoom_call_count),
|
||||
zoomClientCallCount: parseInt(row.zoom_client_call_count),
|
||||
zoomCallDurationSeconds: parseInt(row.zoom_call_duration_seconds),
|
||||
zoomMeetingCount: parseInt(row.zoom_meeting_count),
|
||||
zoomClientMeetingCount: parseInt(row.zoom_client_meeting_count),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
users,
|
||||
pagination: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ENGAGEMENT-USERS] Error:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch engagement users' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
25
app/api/notifications/morning-summary/config/route.ts
Normal file
25
app/api/notifications/morning-summary/config/route.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getMorningSummaryService } from '@/lib/services/morning-summary-service';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const service = getMorningSummaryService();
|
||||
const config = await service.getConfig();
|
||||
return NextResponse.json({ config });
|
||||
} catch (error) {
|
||||
console.error('[GET /api/notifications/morning-summary/config]', error);
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const service = getMorningSummaryService();
|
||||
const config = await service.updateConfig(body);
|
||||
return NextResponse.json({ config });
|
||||
} catch (error) {
|
||||
console.error('[PUT /api/notifications/morning-summary/config]', error);
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
14
app/api/notifications/morning-summary/history/route.ts
Normal file
14
app/api/notifications/morning-summary/history/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { getMorningSummaryService } from '@/lib/services/morning-summary-service';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const service = getMorningSummaryService();
|
||||
const history = await service.getSummaryHistory(10);
|
||||
const latest = await service.getLatestSummaryRow();
|
||||
return NextResponse.json({ history, latest });
|
||||
} catch (error) {
|
||||
console.error('[GET /api/notifications/morning-summary/history]', error);
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
23
app/api/notifications/morning-summary/send/route.ts
Normal file
23
app/api/notifications/morning-summary/send/route.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getMorningSummaryService } from '@/lib/services/morning-summary-service';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const webhookIds: number[] | undefined = body.webhookIds;
|
||||
|
||||
const service = getMorningSummaryService();
|
||||
const { summary, results } = await service.run(webhookIds);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
openCount: summary.openCount,
|
||||
resolvedCount: summary.resolvedCount,
|
||||
isWeekendWindow: summary.isWeekendWindow,
|
||||
results,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[POST /api/notifications/morning-summary/send]', error);
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
21
app/api/notifications/morning-summary/test/route.ts
Normal file
21
app/api/notifications/morning-summary/test/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getMorningSummaryService } from '@/lib/services/morning-summary-service';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { webhookId } = body as { webhookId: number };
|
||||
|
||||
if (!webhookId) {
|
||||
return NextResponse.json({ error: 'webhookId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const service = getMorningSummaryService();
|
||||
const result = await service.testSend(webhookId);
|
||||
|
||||
return NextResponse.json({ success: result.success, result });
|
||||
} catch (error) {
|
||||
console.error('[POST /api/notifications/morning-summary/test]', error);
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
33
app/api/notifications/morning-summary/webhooks/[id]/route.ts
Normal file
33
app/api/notifications/morning-summary/webhooks/[id]/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getMorningSummaryService } from '@/lib/services/morning-summary-service';
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id: rawId } = await params;
|
||||
const id = parseInt(rawId, 10);
|
||||
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
|
||||
const body = await request.json();
|
||||
const service = getMorningSummaryService();
|
||||
const webhook = await service.updateWebhook(id, body);
|
||||
return NextResponse.json({ webhook });
|
||||
} catch (error) {
|
||||
console.error('[PUT /api/notifications/morning-summary/webhooks/:id]', error);
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id: rawId } = await params;
|
||||
const id = parseInt(rawId, 10);
|
||||
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
|
||||
const service = getMorningSummaryService();
|
||||
await service.deleteWebhook(id);
|
||||
return NextResponse.json({ deleted: true });
|
||||
} catch (error) {
|
||||
console.error('[DELETE /api/notifications/morning-summary/webhooks/:id]', error);
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
31
app/api/notifications/morning-summary/webhooks/route.ts
Normal file
31
app/api/notifications/morning-summary/webhooks/route.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getMorningSummaryService } from '@/lib/services/morning-summary-service';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const service = getMorningSummaryService();
|
||||
const webhooks = await service.getWebhooks();
|
||||
return NextResponse.json({ webhooks });
|
||||
} catch (error) {
|
||||
console.error('[GET /api/notifications/morning-summary/webhooks]', error);
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { label, webhook_url } = body as { label: string; webhook_url: string };
|
||||
|
||||
if (!label || !webhook_url) {
|
||||
return NextResponse.json({ error: 'label and webhook_url are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const service = getMorningSummaryService();
|
||||
const webhook = await service.createWebhook(label, webhook_url);
|
||||
return NextResponse.json({ webhook }, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('[POST /api/notifications/morning-summary/webhooks]', error);
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -40,13 +40,102 @@ export async function GET() {
|
|||
'SELECT MAX(computed_at) as computed_at FROM veeam_compliance_results'
|
||||
);
|
||||
|
||||
// Get mismatch details
|
||||
// Get mismatch details with active contract service coverage.
|
||||
// "Covered" = company has an active contract with a ContractService line
|
||||
// matching workstation backup (service_name ILIKE '%workstation%backup%' or '%w/ backup%').
|
||||
// Falls back to billing_items if contract_services not yet populated.
|
||||
const mismatches = await postgresClient.query(`
|
||||
WITH cs_backup AS (
|
||||
SELECT DISTINCT ON (ct.company_id)
|
||||
ct.company_id,
|
||||
ct.id AS contract_id,
|
||||
ct.contract_name,
|
||||
cs.quantity AS contracted_qty
|
||||
FROM contract_services cs
|
||||
JOIN contracts ct ON ct.id = cs.contract_id
|
||||
WHERE cs.is_deleted = false
|
||||
AND ct.is_deleted = false
|
||||
AND ct.status = 1
|
||||
AND (
|
||||
cs.service_name ILIKE '%workstation%backup%'
|
||||
OR cs.service_name ILIKE '%w/ backup%'
|
||||
OR cs.description ILIKE '%workstation%backup%'
|
||||
OR cs.description ILIKE '%w/ backup%'
|
||||
OR cs.service_name ILIKE '%windows server%'
|
||||
OR cs.service_name ILIKE '%server virtual%'
|
||||
OR cs.service_name ILIKE '%server physical%'
|
||||
OR cs.service_name ILIKE '%server phys%'
|
||||
OR cs.service_name ILIKE '%esxi host%'
|
||||
OR cs.service_name ILIKE '%wulf 365 it complete endpoint%'
|
||||
OR cs.service_name ILIKE '%wulf 365 it complete server%'
|
||||
OR cs.service_name ILIKE '%wulf it complete (server)%'
|
||||
OR cs.service_name ILIKE '%wulf it complete (endpoint)%'
|
||||
)
|
||||
ORDER BY ct.company_id,
|
||||
CASE
|
||||
WHEN ct.contract_name ILIKE '%backup%' THEN 0
|
||||
WHEN ct.contract_name ILIKE '%managed it%' THEN 1
|
||||
WHEN ct.contract_name ILIKE '%fixed price%' THEN 2
|
||||
ELSE 3
|
||||
END
|
||||
),
|
||||
bi_backup AS (
|
||||
SELECT DISTINCT ON (bi.company_id)
|
||||
bi.company_id,
|
||||
ct.id AS contract_id,
|
||||
ct.contract_name,
|
||||
bi.quantity AS contracted_qty
|
||||
FROM billing_items bi
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, contract_name
|
||||
FROM contracts
|
||||
WHERE company_id = bi.company_id
|
||||
AND is_deleted = false AND status = 1
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN contract_name ILIKE '%backup%' THEN 0
|
||||
WHEN contract_name ILIKE '%managed it%' THEN 1
|
||||
WHEN contract_name ILIKE '%fixed price%' THEN 2
|
||||
ELSE 3
|
||||
END
|
||||
LIMIT 1
|
||||
) ct ON true
|
||||
WHERE bi.is_deleted = false
|
||||
AND bi.description ILIKE '%windows workstation w/ backup%'
|
||||
AND bi.synced_at::date = (
|
||||
SELECT MAX(synced_at::date) FROM billing_items WHERE is_deleted = false
|
||||
)
|
||||
ORDER BY bi.company_id, bi.quantity DESC
|
||||
),
|
||||
coverage AS (
|
||||
SELECT
|
||||
company_id,
|
||||
contract_id,
|
||||
contract_name,
|
||||
contracted_qty,
|
||||
'contract_services' AS source
|
||||
FROM cs_backup
|
||||
UNION ALL
|
||||
SELECT
|
||||
b.company_id,
|
||||
b.contract_id,
|
||||
b.contract_name,
|
||||
b.contracted_qty,
|
||||
'billing_items' AS source
|
||||
FROM bi_backup b
|
||||
WHERE NOT EXISTS (SELECT 1 FROM cs_backup cs WHERE cs.company_id = b.company_id)
|
||||
)
|
||||
SELECT
|
||||
cr.*,
|
||||
c.company_name
|
||||
c.company_name,
|
||||
cov.contract_name AS billing_contract_name,
|
||||
cov.contract_id AS billing_contract_id,
|
||||
(cov.company_id IS NOT NULL) AS billing_covered,
|
||||
cov.contracted_qty::int AS billing_contracted_qty,
|
||||
cov.source AS coverage_source
|
||||
FROM veeam_compliance_results cr
|
||||
LEFT JOIN companies c ON c.id = cr.company_id
|
||||
LEFT JOIN coverage cov ON cov.company_id = cr.company_id
|
||||
ORDER BY cr.mismatch_type, c.company_name, cr.device_name
|
||||
`);
|
||||
|
||||
|
|
|
|||
181
app/api/veeam/contract-coverage/route.ts
Normal file
181
app/api/veeam/contract-coverage/route.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* Contract Coverage API
|
||||
* GET /api/veeam/contract-coverage
|
||||
* Returns per-company contracted vs deployed counts for Servers, Workstations, M365
|
||||
* plus the individual contract service lines for the expanded view.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const result = await postgresClient.query(`
|
||||
WITH
|
||||
|
||||
-- All active contract service lines with category classification
|
||||
cs_lines AS (
|
||||
SELECT
|
||||
ct.company_id,
|
||||
ct.id AS contract_id,
|
||||
ct.contract_name,
|
||||
cs.id AS cs_id,
|
||||
cs.service_name AS line_name,
|
||||
cs.unit_price,
|
||||
cs.unit_cost,
|
||||
CASE
|
||||
WHEN cs.service_name ILIKE '%windows server%'
|
||||
OR cs.service_name ILIKE '%server virtual%'
|
||||
OR cs.service_name ILIKE '%server phys%'
|
||||
OR cs.service_name ILIKE '%esxi host%'
|
||||
OR cs.service_name ILIKE '%wulf 365 it complete server%'
|
||||
OR cs.service_name ILIKE '%wulf it complete (server)%'
|
||||
THEN 'server'
|
||||
WHEN cs.service_name ILIKE '%workstation%backup%'
|
||||
OR cs.service_name ILIKE '%w/ backup%'
|
||||
OR cs.service_name ILIKE '%wulf 365 it complete endpoint%'
|
||||
OR cs.service_name ILIKE '%wulf it complete (endpoint)%'
|
||||
THEN 'workstation'
|
||||
WHEN cs.service_name ILIKE '%microsoft 365%'
|
||||
OR cs.service_name ILIKE '%office 365%'
|
||||
OR cs.service_name ILIKE '%exchange online%'
|
||||
OR cs.service_name ILIKE '%m365%'
|
||||
OR cs.service_name ILIKE '%veeam backup for microsoft office 365%'
|
||||
THEN 'm365'
|
||||
ELSE 'other'
|
||||
END AS category
|
||||
FROM contract_services cs
|
||||
JOIN contracts ct ON ct.id = cs.contract_id
|
||||
WHERE cs.is_deleted = false
|
||||
AND ct.is_deleted = false
|
||||
AND ct.status = 1
|
||||
),
|
||||
|
||||
-- Summarise contracted counts per company
|
||||
contracted AS (
|
||||
SELECT
|
||||
company_id,
|
||||
COUNT(*) FILTER (WHERE category = 'server') AS contracted_servers,
|
||||
COUNT(*) FILTER (WHERE category = 'workstation') AS contracted_workstations,
|
||||
COUNT(*) FILTER (WHERE category = 'm365') AS contracted_m365,
|
||||
COUNT(*) FILTER (WHERE category = 'other') AS contracted_other,
|
||||
COUNT(*) AS contracted_total
|
||||
FROM cs_lines
|
||||
GROUP BY company_id
|
||||
),
|
||||
|
||||
-- Deployed counts from Veeam agent jobs
|
||||
deployed AS (
|
||||
SELECT
|
||||
o.company_id,
|
||||
COUNT(*) FILTER (WHERE j.operation_mode = 'Server') AS deployed_servers,
|
||||
COUNT(*) FILTER (WHERE j.operation_mode = 'Workstation') AS deployed_workstations,
|
||||
COUNT(*) FILTER (WHERE j.operation_mode NOT IN ('Server','Workstation')) AS deployed_other
|
||||
FROM veeam_backup_agent_jobs j
|
||||
JOIN veeam_organizations o ON o.instance_uid = j.organization_uid
|
||||
WHERE j.is_enabled = true
|
||||
GROUP BY o.company_id
|
||||
),
|
||||
|
||||
-- All companies that appear in either side
|
||||
all_companies AS (
|
||||
SELECT company_id FROM contracted
|
||||
UNION
|
||||
SELECT company_id FROM deployed
|
||||
)
|
||||
|
||||
SELECT
|
||||
ac.company_id,
|
||||
c.company_name,
|
||||
COALESCE(ct.contracted_servers, 0) AS contracted_servers,
|
||||
COALESCE(ct.contracted_workstations, 0) AS contracted_workstations,
|
||||
COALESCE(ct.contracted_m365, 0) AS contracted_m365,
|
||||
COALESCE(ct.contracted_other, 0) AS contracted_other,
|
||||
COALESCE(d.deployed_servers, 0) AS deployed_servers,
|
||||
COALESCE(d.deployed_workstations, 0) AS deployed_workstations,
|
||||
COALESCE(d.deployed_other, 0) AS deployed_other
|
||||
FROM all_companies ac
|
||||
JOIN companies c ON c.id = ac.company_id
|
||||
LEFT JOIN contracted ct ON ct.company_id = ac.company_id
|
||||
LEFT JOIN deployed d ON d.company_id = ac.company_id
|
||||
ORDER BY c.company_name
|
||||
`);
|
||||
|
||||
// Build per-company service lines map
|
||||
const linesResult = await postgresClient.query(`
|
||||
SELECT
|
||||
ct.company_id,
|
||||
ct.id AS contract_id,
|
||||
ct.contract_name,
|
||||
cs.id AS cs_id,
|
||||
cs.service_name AS line_name,
|
||||
cs.unit_price,
|
||||
cs.unit_cost,
|
||||
CASE
|
||||
WHEN cs.service_name ILIKE '%windows server%'
|
||||
OR cs.service_name ILIKE '%server virtual%'
|
||||
OR cs.service_name ILIKE '%server phys%'
|
||||
OR cs.service_name ILIKE '%esxi host%'
|
||||
OR cs.service_name ILIKE '%wulf 365 it complete server%'
|
||||
OR cs.service_name ILIKE '%wulf it complete (server)%'
|
||||
THEN 'server'
|
||||
WHEN cs.service_name ILIKE '%workstation%backup%'
|
||||
OR cs.service_name ILIKE '%w/ backup%'
|
||||
OR cs.service_name ILIKE '%wulf 365 it complete endpoint%'
|
||||
OR cs.service_name ILIKE '%wulf it complete (endpoint)%'
|
||||
THEN 'workstation'
|
||||
WHEN cs.service_name ILIKE '%microsoft 365%'
|
||||
OR cs.service_name ILIKE '%office 365%'
|
||||
OR cs.service_name ILIKE '%exchange online%'
|
||||
OR cs.service_name ILIKE '%m365%'
|
||||
OR cs.service_name ILIKE '%veeam backup for microsoft office 365%'
|
||||
THEN 'm365'
|
||||
ELSE 'other'
|
||||
END AS category
|
||||
FROM contract_services cs
|
||||
JOIN contracts ct ON ct.id = cs.contract_id
|
||||
WHERE cs.is_deleted = false
|
||||
AND ct.is_deleted = false
|
||||
AND ct.status = 1
|
||||
ORDER BY ct.company_id, ct.contract_name, cs.service_name
|
||||
`);
|
||||
|
||||
// Group lines by company_id
|
||||
const linesByCompany: Record<number, typeof linesResult.rows> = {};
|
||||
for (const row of linesResult.rows) {
|
||||
const cid = row.company_id;
|
||||
if (!linesByCompany[cid]) linesByCompany[cid] = [];
|
||||
linesByCompany[cid].push(row);
|
||||
}
|
||||
|
||||
const rows = result.rows.map((r) => ({
|
||||
company_id: r.company_id,
|
||||
company_name: r.company_name,
|
||||
contracted: {
|
||||
servers: Number(r.contracted_servers),
|
||||
workstations: Number(r.contracted_workstations),
|
||||
m365: Number(r.contracted_m365),
|
||||
other: Number(r.contracted_other),
|
||||
},
|
||||
deployed: {
|
||||
servers: Number(r.deployed_servers),
|
||||
workstations: Number(r.deployed_workstations),
|
||||
other: Number(r.deployed_other),
|
||||
},
|
||||
lines: (linesByCompany[r.company_id] || []).map((l) => ({
|
||||
cs_id: l.cs_id,
|
||||
contract_id: l.contract_id,
|
||||
contract_name: l.contract_name,
|
||||
line_name: l.line_name,
|
||||
unit_price: l.unit_price != null ? Number(l.unit_price) : null,
|
||||
unit_cost: l.unit_cost != null ? Number(l.unit_cost) : null,
|
||||
category: l.category,
|
||||
})),
|
||||
}));
|
||||
|
||||
return NextResponse.json({ rows });
|
||||
} catch (error) {
|
||||
console.error('[contract-coverage] error:', error);
|
||||
return NextResponse.json({ rows: [] });
|
||||
}
|
||||
}
|
||||
126
app/api/zabbix/create-host/route.ts
Normal file
126
app/api/zabbix/create-host/route.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* Manual Zabbix Host Creation API
|
||||
* POST /api/zabbix/create-host
|
||||
*
|
||||
* Creates a Zabbix host with ICMP monitoring using the same logic as the
|
||||
* RMM discovery flow, but with a user-supplied IP and site name instead
|
||||
* of auto-discovered WAN IPs.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { ZabbixClient } from '@/lib/services/zabbix-client';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
import {
|
||||
lookupIsp,
|
||||
buildHostParams,
|
||||
discoverIcmpTemplate,
|
||||
} from '@/lib/services/zabbix-wan-utils';
|
||||
|
||||
interface CreateHostBody {
|
||||
ip: string;
|
||||
siteName: string;
|
||||
companyId?: number;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: CreateHostBody = await request.json();
|
||||
const { ip, siteName, companyId, dryRun = false } = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!ip || !siteName) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ip and siteName are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Basic IPv4 validation
|
||||
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
if (!ipv4Regex.test(ip)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid IPv4 address format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Zabbix is not configured. Add ZABBIX_API_URL and ZABBIX_API_TOKEN to your environment.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve company name if companyId provided
|
||||
let companyName: string | undefined;
|
||||
if (companyId) {
|
||||
const res = await postgresClient.query<{ company_name: string }>(
|
||||
'SELECT company_name FROM companies WHERE id = $1 LIMIT 1',
|
||||
[companyId]
|
||||
);
|
||||
companyName = res.rows[0]?.company_name;
|
||||
}
|
||||
|
||||
// ISP lookup
|
||||
const ispInfo = await lookupIsp(ip);
|
||||
|
||||
// Dry-run: return what would happen without writing to Zabbix
|
||||
if (dryRun) {
|
||||
return NextResponse.json({
|
||||
action: 'skipped',
|
||||
dryRun: true,
|
||||
siteName,
|
||||
ip,
|
||||
companyId: companyId ?? null,
|
||||
companyName: companyName ?? null,
|
||||
isp: ispInfo?.isp ?? null,
|
||||
asn: ispInfo?.asn ?? null,
|
||||
hostId: null,
|
||||
});
|
||||
}
|
||||
|
||||
const zabbix = new ZabbixClient({
|
||||
apiUrl: process.env.ZABBIX_API_URL!,
|
||||
apiToken: process.env.ZABBIX_API_TOKEN!,
|
||||
});
|
||||
|
||||
// Ensure host group + discover ICMP template
|
||||
const globalGroupId = await zabbix.ensureHostGroup('Datto RMM Sites');
|
||||
const icmpTemplateId = await discoverIcmpTemplate(zabbix);
|
||||
|
||||
// Build host params using the shared utility
|
||||
const hostParams = await buildHostParams({
|
||||
siteName,
|
||||
wanIp: ip,
|
||||
companyId,
|
||||
companyName,
|
||||
ispInfo,
|
||||
source: 'manual',
|
||||
icmpTemplateId,
|
||||
globalGroupId,
|
||||
zabbix,
|
||||
});
|
||||
|
||||
// Create or update the host
|
||||
const { action, hostid } = await zabbix.upsertHost(hostParams);
|
||||
|
||||
return NextResponse.json({
|
||||
action,
|
||||
dryRun: false,
|
||||
siteName,
|
||||
ip,
|
||||
companyId: companyId ?? null,
|
||||
companyName: companyName ?? null,
|
||||
isp: ispInfo?.isp ?? null,
|
||||
asn: ispInfo?.asn ?? null,
|
||||
hostId: hostid,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[create-host] error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
142
app/api/zabbix/hosts/[hostid]/route.ts
Normal file
142
app/api/zabbix/hosts/[hostid]/route.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* PUT /api/zabbix/hosts/[hostid] — full update of an existing Zabbix host
|
||||
*
|
||||
* If companyId is provided (or changed), rebuilds host groups / macros / tags
|
||||
* via buildHostParams (same as creation flow). Otherwise applies fields directly.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { ZabbixClient } from '@/lib/services/zabbix-client';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
import { ZabbixHostMacro, ZabbixHostTag } from '@/lib/types/zabbix';
|
||||
import {
|
||||
sanitizeHostname,
|
||||
lookupIsp,
|
||||
buildHostParams,
|
||||
discoverIcmpTemplate,
|
||||
} from '@/lib/services/zabbix-wan-utils';
|
||||
|
||||
interface UpdateBody {
|
||||
name: string;
|
||||
ip: string;
|
||||
description?: string;
|
||||
companyId?: number | null;
|
||||
tags?: ZabbixHostTag[];
|
||||
macros?: ZabbixHostMacro[];
|
||||
rebuildFromClient?: boolean; // if true, fully re-run buildHostParams
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ hostid: string }> }
|
||||
) {
|
||||
try {
|
||||
const { hostid } = await params;
|
||||
const body: UpdateBody = await request.json();
|
||||
const { name, ip, description, companyId, tags, macros, rebuildFromClient } = body;
|
||||
|
||||
if (!name || !ip) {
|
||||
return NextResponse.json({ error: 'name and ip are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
if (!ipv4Regex.test(ip)) {
|
||||
return NextResponse.json({ error: 'Invalid IPv4 address' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) {
|
||||
return NextResponse.json({ error: 'Zabbix not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const zabbix = new ZabbixClient({
|
||||
apiUrl: process.env.ZABBIX_API_URL!,
|
||||
apiToken: process.env.ZABBIX_API_TOKEN!,
|
||||
});
|
||||
|
||||
if (rebuildFromClient) {
|
||||
// Full rebuild: re-resolve ISP, rebuild groups/macros/tags from scratch
|
||||
let companyName: string | undefined;
|
||||
if (companyId) {
|
||||
const res = await postgresClient.query<{ company_name: string }>(
|
||||
'SELECT company_name FROM companies WHERE id = $1 LIMIT 1',
|
||||
[companyId]
|
||||
);
|
||||
companyName = res.rows[0]?.company_name;
|
||||
}
|
||||
|
||||
const ispInfo = await lookupIsp(ip);
|
||||
const globalGroupId = await zabbix.ensureHostGroup('Datto RMM Sites');
|
||||
const icmpTemplateId = await discoverIcmpTemplate(zabbix);
|
||||
|
||||
const hostParams = await buildHostParams({
|
||||
siteName: name,
|
||||
wanIp: ip,
|
||||
companyId: companyId ?? undefined,
|
||||
companyName,
|
||||
ispInfo,
|
||||
source: 'manual',
|
||||
icmpTemplateId,
|
||||
globalGroupId,
|
||||
zabbix,
|
||||
});
|
||||
|
||||
await zabbix['rpc']('host.update', {
|
||||
hostid,
|
||||
host: sanitizeHostname(name),
|
||||
name,
|
||||
description: description ?? hostParams.description,
|
||||
groups: hostParams.groups,
|
||||
templates: hostParams.templates,
|
||||
macros: hostParams.macros,
|
||||
tags: hostParams.tags,
|
||||
});
|
||||
|
||||
// Update IP interface separately
|
||||
const existingHost = await zabbix['rpc']<Array<{ interfaces: Array<{ interfaceid: string; main: number }> }>>('host.get', {
|
||||
output: ['hostid'],
|
||||
hostids: [hostid],
|
||||
selectInterfaces: ['interfaceid', 'main', 'type'],
|
||||
});
|
||||
const mainIface = existingHost[0]?.interfaces?.find((i: any) => i.main === 1 || i.main === '1');
|
||||
if (mainIface) {
|
||||
await zabbix['rpc']('hostinterface.update', {
|
||||
interfaceid: mainIface.interfaceid,
|
||||
ip,
|
||||
useip: 1,
|
||||
dns: '',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Direct update — apply exactly what was sent
|
||||
await zabbix['rpc']('host.update', {
|
||||
hostid,
|
||||
host: sanitizeHostname(name),
|
||||
name,
|
||||
...(description !== undefined ? { description } : {}),
|
||||
...(tags !== undefined ? { tags } : {}),
|
||||
...(macros !== undefined ? { macros } : {}),
|
||||
});
|
||||
|
||||
// Update IP interface
|
||||
const existingHost = await zabbix['rpc']<Array<{ interfaces: Array<{ interfaceid: string; main: number }> }>>('host.get', {
|
||||
output: ['hostid'],
|
||||
hostids: [hostid],
|
||||
selectInterfaces: ['interfaceid', 'main', 'type'],
|
||||
});
|
||||
const mainIface = existingHost[0]?.interfaces?.find((i: any) => i.main === 1 || i.main === '1');
|
||||
if (mainIface) {
|
||||
await zabbix['rpc']('hostinterface.update', {
|
||||
interfaceid: mainIface.interfaceid,
|
||||
ip,
|
||||
useip: 1,
|
||||
dns: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ updated: true, hostid });
|
||||
} catch (error) {
|
||||
console.error('[PUT /api/zabbix/hosts/[hostid]]', error);
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
68
app/api/zabbix/hosts/route.ts
Normal file
68
app/api/zabbix/hosts/route.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* GET /api/zabbix/hosts — list all hosts with full detail + RMM match status
|
||||
* DELETE /api/zabbix/hosts — bulk delete by hostid array
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { ZabbixClient } from '@/lib/services/zabbix-client';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
|
||||
function makeZabbix() {
|
||||
if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) {
|
||||
throw new Error('Zabbix is not configured. Set ZABBIX_API_URL and ZABBIX_API_TOKEN.');
|
||||
}
|
||||
return new ZabbixClient({
|
||||
apiUrl: process.env.ZABBIX_API_URL!,
|
||||
apiToken: process.env.ZABBIX_API_TOKEN!,
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const zabbix = makeZabbix();
|
||||
const hosts = await zabbix.getHosts();
|
||||
|
||||
// Load all known RMM site UIDs from Postgres for mismatch detection
|
||||
const res = await postgresClient.query<{ rmm_site_uid: string }>(
|
||||
'SELECT rmm_site_uid FROM rmm_site_mappings'
|
||||
);
|
||||
const knownSiteUids = new Set(res.rows.map((r) => r.rmm_site_uid));
|
||||
|
||||
// Annotate each host with rmmMatched flag
|
||||
const annotated = hosts.map((h) => {
|
||||
const rmmUidMacro = h.macros?.find((m) => m.macro === '{$RMM_SITE_UID}');
|
||||
const sourceTag = h.tags?.find((t) => t.tag === 'source')?.value ?? null;
|
||||
|
||||
let rmmMatched: boolean | null = null;
|
||||
if (sourceTag === 'datto-rmm') {
|
||||
rmmMatched = rmmUidMacro ? knownSiteUids.has(rmmUidMacro.value) : false;
|
||||
}
|
||||
|
||||
return { ...h, rmmMatched, sourceTag };
|
||||
});
|
||||
|
||||
return NextResponse.json({ hosts: annotated });
|
||||
} catch (error) {
|
||||
console.error('[GET /api/zabbix/hosts]', error);
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { hostids } = body as { hostids: string[] };
|
||||
|
||||
if (!Array.isArray(hostids) || hostids.length === 0) {
|
||||
return NextResponse.json({ error: 'hostids array is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const zabbix = makeZabbix();
|
||||
await zabbix.deleteHosts(hostids);
|
||||
|
||||
return NextResponse.json({ deleted: hostids.length });
|
||||
} catch (error) {
|
||||
console.error('[DELETE /api/zabbix/hosts]', error);
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
21
app/api/zabbix/public-ip/route.ts
Normal file
21
app/api/zabbix/public-ip/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* GET /api/zabbix/public-ip
|
||||
* Returns the server's current public IP address.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const res = await fetch('https://ipinfo.io/json', {
|
||||
headers: { Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`ipinfo ${res.status}`);
|
||||
const data = await res.json();
|
||||
return NextResponse.json({ ip: data.ip ?? null });
|
||||
} catch {
|
||||
return NextResponse.json({ ip: null });
|
||||
}
|
||||
}
|
||||
|
|
@ -3,21 +3,18 @@ import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
|
|||
import { ZabbixClient } from '@/lib/services/zabbix-client';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
|
||||
import { ZabbixHostMacro, ZabbixHostTag } from '@/lib/types/zabbix';
|
||||
import {
|
||||
lookupIsp,
|
||||
clearIspCache,
|
||||
buildHostParams,
|
||||
discoverIcmpTemplate,
|
||||
} from '@/lib/services/zabbix-wan-utils';
|
||||
|
||||
export const maxDuration = 300;
|
||||
|
||||
type SyncMode = 'all' | 'client' | 'site';
|
||||
type SiteAction = 'created' | 'updated' | 'filtered' | 'no-ip' | 'error' | 'skipped';
|
||||
|
||||
interface IspInfo {
|
||||
isp: string; // "Comcast Cable Communications, LLC"
|
||||
asn: string; // "AS7922"
|
||||
city: string;
|
||||
region: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
interface WanResolution {
|
||||
ip: string | null;
|
||||
count: number;
|
||||
|
|
@ -151,61 +148,8 @@ function resolveWanIp(
|
|||
return { ip: topIp, count: topCount, multiWan, allIps, singleDeviceFallback };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ISP lookup via ipinfo.io (free, no key required for basic fields)
|
||||
// Results are cached within a run to avoid duplicate lookups for the same IP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ispCache = new Map<string, IspInfo | null>();
|
||||
|
||||
async function lookupIsp(ip: string): Promise<IspInfo | null> {
|
||||
if (ispCache.has(ip)) return ispCache.get(ip)!;
|
||||
|
||||
try {
|
||||
const token = process.env.IPINFO_TOKEN;
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`https://ipinfo.io/${ip}/json`, {
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(6000),
|
||||
});
|
||||
if (!res.ok) { ispCache.set(ip, null); return null; }
|
||||
|
||||
const data = await res.json();
|
||||
// org field format: "AS7922 Comcast Cable Communications, LLC"
|
||||
const org: string = data.org ?? '';
|
||||
const m = org.match(/^(AS\d+)\s+(.+)$/);
|
||||
|
||||
const info: IspInfo = {
|
||||
isp: m ? m[2] : org,
|
||||
asn: m ? m[1] : '',
|
||||
city: data.city ?? '',
|
||||
region: data.region ?? '',
|
||||
country: data.country ?? '',
|
||||
};
|
||||
ispCache.set(ip, info);
|
||||
return info;
|
||||
} catch {
|
||||
ispCache.set(ip, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zabbix host technical name sanitization
|
||||
// Zabbix rejects: + ' , . & ( ) and other special chars in the `host` field.
|
||||
// We sanitize to alphanumeric, spaces, hyphens, underscores only.
|
||||
// The display `name` field is left as-is (accepts any UTF-8).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sanitizeHostname(name: string): string {
|
||||
return name
|
||||
.replace(/[^a-zA-Z0-9 \-_]/g, '') // strip disallowed chars
|
||||
.replace(/\s+/g, ' ') // collapse multiple spaces
|
||||
.trim();
|
||||
}
|
||||
// ISP lookup, hostname sanitization, and host param building imported from
|
||||
// @/lib/services/zabbix-wan-utils
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API route
|
||||
|
|
@ -223,7 +167,7 @@ export async function POST(request: NextRequest) {
|
|||
dryRun = false,
|
||||
} = body;
|
||||
|
||||
ispCache.clear(); // fresh cache per request
|
||||
clearIspCache(); // fresh cache per request
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const transform = new TransformStream<Uint8Array, Uint8Array>();
|
||||
|
|
@ -252,10 +196,7 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
if (!dryRun) {
|
||||
globalGroupId = await zabbix.ensureHostGroup('Datto RMM Sites');
|
||||
for (const name of ['ICMP Ping', 'Template Module ICMP Ping', 'Template Module ICMP Ping by Zabbix agent']) {
|
||||
const tmpl = await zabbix.findTemplate(name);
|
||||
if (tmpl) { icmpTemplateId = tmpl.templateid; break; }
|
||||
}
|
||||
icmpTemplateId = await discoverIcmpTemplate(zabbix);
|
||||
}
|
||||
|
||||
// Load site → Autotask mappings (keyed by RMM site UID)
|
||||
|
|
@ -364,75 +305,26 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
try {
|
||||
const onlineCount = devices.filter((d) => d.online && !d.suspended && !d.deleted).length;
|
||||
const templates = icmpTemplateId ? [{ templateid: icmpTemplateId }] : undefined;
|
||||
|
||||
// Build groups: always global, + per-client, + per-ISP
|
||||
const groups: Array<{ groupid: string }> = [{ groupid: globalGroupId }];
|
||||
|
||||
if (mapping) {
|
||||
const clientGroupId = await zabbix.ensureHostGroup(`Clients/${mapping.companyName}`);
|
||||
groups.push({ groupid: clientGroupId });
|
||||
}
|
||||
if (ispInfo?.isp) {
|
||||
const ispGroupId = await zabbix.ensureHostGroup(`ISP/${ispInfo.isp}`);
|
||||
groups.push({ groupid: ispGroupId });
|
||||
}
|
||||
|
||||
// Build macros: Autotask identity + ISP context
|
||||
const macros: ZabbixHostMacro[] = [];
|
||||
if (mapping) {
|
||||
macros.push(
|
||||
{ macro: '{$AUTOTASK_COMPANY_ID}', value: String(mapping.companyId), description: 'Autotask company ID' },
|
||||
{ macro: '{$AUTOTASK_COMPANY_NAME}', value: mapping.companyName, description: 'Autotask company name' },
|
||||
{ macro: '{$RMM_SITE_UID}', value: site.uid, description: 'Datto RMM site UID' },
|
||||
);
|
||||
}
|
||||
if (ispInfo) {
|
||||
macros.push(
|
||||
{ macro: '{$ISP_NAME}', value: ispInfo.isp, description: 'ISP / carrier name' },
|
||||
{ macro: '{$ASN}', value: ispInfo.asn, description: 'Autonomous System Number' },
|
||||
{ macro: '{$ISP_CITY}', value: ispInfo.city, description: 'City (from IP geolocation)' },
|
||||
{ macro: '{$ISP_REGION}', value: ispInfo.region, description: 'Region (from IP geolocation)' },
|
||||
{ macro: '{$ISP_COUNTRY}', value: ispInfo.country, description: 'Country code (from IP geolocation)' },
|
||||
);
|
||||
}
|
||||
if (multiWan) {
|
||||
macros.push({ macro: '{$MULTI_WAN_IPS}', value: allIps.join(', '), description: 'All public IPs seen (multi-WAN site)' });
|
||||
}
|
||||
|
||||
// Build tags: for dashboard filtering and problem correlation
|
||||
const tags: ZabbixHostTag[] = [{ tag: 'source', value: 'datto-rmm' }];
|
||||
if (mapping) {
|
||||
tags.push({ tag: 'client', value: mapping.companyName });
|
||||
}
|
||||
if (ispInfo?.isp) {
|
||||
tags.push({ tag: 'isp', value: ispInfo.isp });
|
||||
}
|
||||
if (ispInfo?.asn) {
|
||||
tags.push({ tag: 'asn', value: ispInfo.asn });
|
||||
}
|
||||
if (multiWan) {
|
||||
tags.push({ tag: 'multi-wan', value: 'true' });
|
||||
}
|
||||
if (singleDeviceFallback) {
|
||||
tags.push({ tag: 'single-device-fallback', value: 'true' });
|
||||
}
|
||||
|
||||
const description = [
|
||||
`Datto RMM site – WAN IP from ${onlineCount} online devices`,
|
||||
ispInfo ? `ISP: ${ispInfo.isp} (${ispInfo.asn}) — ${ispInfo.city}, ${ispInfo.region}, ${ispInfo.country}` : null,
|
||||
multiWan ? `Multi-WAN detected: ${allIps.join(', ')}` : null,
|
||||
singleDeviceFallback ? `Note: IP sourced from single device (no multi-device confirmation)` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
const { action, hostid } = await zabbix.upsertHost({
|
||||
host: sanitizeHostname(site.name), name: site.name, description,
|
||||
interfaces: [{ type: 1, main: 1, useip: 1, ip: wanIp, dns: '', port: '10050' }],
|
||||
groups, templates,
|
||||
macros: macros.length > 0 ? macros : undefined,
|
||||
tags,
|
||||
const hostParams = await buildHostParams({
|
||||
siteName: site.name,
|
||||
wanIp,
|
||||
companyId: mapping?.companyId,
|
||||
companyName: mapping?.companyName,
|
||||
rmmSiteUid: site.uid,
|
||||
ispInfo,
|
||||
multiWan,
|
||||
allIps,
|
||||
singleDeviceFallback,
|
||||
onlineDeviceCount: onlineCount,
|
||||
source: 'datto-rmm',
|
||||
icmpTemplateId,
|
||||
globalGroupId,
|
||||
zabbix,
|
||||
});
|
||||
|
||||
const { action, hostid } = await zabbix.upsertHost(hostParams);
|
||||
|
||||
if (action === 'created') stats.created++; else stats.updated++;
|
||||
|
||||
await send({ type: 'site', result: {
|
||||
|
|
|
|||
46
app/api/zoom/sync/route.ts
Normal file
46
app/api/zoom/sync/route.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { isZoomConfigured } from '@/lib/services/zoom-factory';
|
||||
import { getZoomSyncService } from '@/lib/services/zoom-sync-service';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
|
||||
export async function POST(_request: NextRequest) {
|
||||
if (!isZoomConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Zoom credentials not configured' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const service = getZoomSyncService();
|
||||
|
||||
if (service.isSyncInProgress()) {
|
||||
return NextResponse.json({ error: 'Zoom sync already in progress' }, { status: 409 });
|
||||
}
|
||||
|
||||
// Fire-and-forget
|
||||
service.sync().catch(err => {
|
||||
console.error('[ZOOM-SYNC] Background sync failed:', err);
|
||||
});
|
||||
|
||||
return NextResponse.json({ started: true });
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
const service = getZoomSyncService();
|
||||
|
||||
let lastSynced: Date | null = null;
|
||||
try {
|
||||
const result = await postgresClient.query(
|
||||
`SELECT MAX(synced_at) as last_synced FROM zoom_users`
|
||||
);
|
||||
lastSynced = result.rows[0]?.last_synced ?? null;
|
||||
} catch {
|
||||
// Table may not exist yet
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
isSyncing: service.isSyncInProgress(),
|
||||
lastSynced,
|
||||
configured: isZoomConfigured(),
|
||||
});
|
||||
}
|
||||
|
|
@ -8,8 +8,11 @@ import { BackupSummaryCards } from '@/components/backup/backup-summary-cards';
|
|||
import { CompanyBackupTable, CompanyBackupRow } from '@/components/backup/company-backup-table';
|
||||
import { ComplianceSummaryCards } from '@/components/backup/compliance-summary-cards';
|
||||
import { ComplianceDetailTable } from '@/components/backup/compliance-detail-table';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { ContractCoverageTable } from '@/components/backup/contract-coverage-table';
|
||||
import { RefreshCw, CheckCircle2, AlertTriangle, XCircle, Clock } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { RpoJobSummary } from '@/lib/services/veeam-rpo-service';
|
||||
|
||||
interface BackupStatusData {
|
||||
totalProtectedWorkloads: number;
|
||||
|
|
@ -22,6 +25,18 @@ interface BackupStatusData {
|
|||
lastSyncAt: string | null;
|
||||
}
|
||||
|
||||
interface RpoData {
|
||||
summary: {
|
||||
total: number;
|
||||
healthy: number;
|
||||
breached: number;
|
||||
withOpenTicket: number;
|
||||
critical: number;
|
||||
high: number;
|
||||
};
|
||||
jobs: RpoJobSummary[];
|
||||
}
|
||||
|
||||
interface ComplianceData {
|
||||
summary: {
|
||||
totalContractedDevices: number;
|
||||
|
|
@ -44,23 +59,33 @@ function timeAgo(dateStr: string | null): string {
|
|||
return `${Math.floor(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
function timeAgoHours(hours: number | null): string {
|
||||
if (hours === null) return 'Never';
|
||||
if (hours < 1) return 'Just now';
|
||||
if (hours < 24) return `${Math.round(hours)}h ago`;
|
||||
return `${Math.round(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
export default function BackupStatusPage() {
|
||||
const [status, setStatus] = useState<BackupStatusData | null>(null);
|
||||
const [companies, setCompanies] = useState<CompanyBackupRow[]>([]);
|
||||
const [compliance, setCompliance] = useState<ComplianceData | null>(null);
|
||||
const [rpo, setRpo] = useState<RpoData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [statusRes, companiesRes, complianceRes] = await Promise.all([
|
||||
const [statusRes, companiesRes, complianceRes, rpoRes] = await Promise.all([
|
||||
fetch('/api/veeam/backup-status').then(r => r.json()),
|
||||
fetch('/api/veeam/companies').then(r => r.json()),
|
||||
fetch('/api/veeam/compliance').then(r => r.json()),
|
||||
fetch('/api/veeam/rpo-check').then(r => r.json()),
|
||||
]);
|
||||
setStatus(statusRes);
|
||||
setCompanies(Array.isArray(companiesRes) ? companiesRes : []);
|
||||
setCompliance(complianceRes);
|
||||
setRpo(rpoRes);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch backup status:', error);
|
||||
} finally {
|
||||
|
|
@ -106,7 +131,7 @@ export default function BackupStatusPage() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container px-6 py-6 space-y-6">
|
||||
<div className="container mx-auto px-6 py-6 space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-5">
|
||||
{[...Array(5)].map((_, i) => <Skeleton key={i} className="h-24" />)}
|
||||
</div>
|
||||
|
|
@ -117,12 +142,20 @@ export default function BackupStatusPage() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<div className="container px-6 py-6 space-y-6">
|
||||
<div className="container mx-auto px-6 py-6 space-y-6">
|
||||
|
||||
<Tabs defaultValue="overview" className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Backup Overview</TabsTrigger>
|
||||
<TabsTrigger value="rpo">
|
||||
RPO Status
|
||||
{rpo && rpo.summary.breached > 0 && (
|
||||
<Badge variant="destructive" className="ml-2 h-5 px-1.5 text-xs">
|
||||
{rpo.summary.breached}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="compliance">
|
||||
Contract Compliance
|
||||
{compliance && (compliance.summary.contractedNotBackedUp > 0 || compliance.summary.backedUpNotContracted > 0) && (
|
||||
|
|
@ -165,6 +198,110 @@ export default function BackupStatusPage() {
|
|||
<CompanyBackupTable companies={companies} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rpo" className="space-y-6">
|
||||
{rpo && (
|
||||
<>
|
||||
{/* Summary Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Healthy Jobs</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{rpo.summary.healthy}</div>
|
||||
<p className="text-xs text-muted-foreground">of {rpo.summary.total} total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">RPO Breached</CardTitle>
|
||||
<XCircle className="h-4 w-4 text-destructive" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-destructive">{rpo.summary.breached}</div>
|
||||
<p className="text-xs text-muted-foreground">{rpo.summary.withOpenTicket} with open ticket</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Critical</CardTitle>
|
||||
<AlertTriangle className="h-4 w-4 text-destructive" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-destructive">{rpo.summary.critical}</div>
|
||||
<p className="text-xs text-muted-foreground">{rpo.summary.high} high priority</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Compliance Rate</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{rpo.summary.total > 0 ? Math.round((rpo.summary.healthy / rpo.summary.total) * 100) : 0}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">jobs within RPO window</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Job Table */}
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">Job</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Organization</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Last Backup</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Ticket</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Failure Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rpo.jobs.map((job) => (
|
||||
<tr key={job.job_instance_uid} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 font-medium">{job.job_name}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{job.org_name}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{timeAgoHours(job.hours_since_backup)}</td>
|
||||
<td className="px-4 py-3">
|
||||
{job.is_breached ? (
|
||||
<Badge variant="destructive">Breached</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-green-600 border-green-600">Healthy</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{job.open_ticket ? (
|
||||
<span className={`text-xs font-mono ${
|
||||
job.open_ticket.priority_level === 'critical' ? 'text-destructive' :
|
||||
job.open_ticket.priority_level === 'high' ? 'text-orange-500' : 'text-muted-foreground'
|
||||
}`}>
|
||||
{job.open_ticket.at_ticket_number} ({job.open_ticket.priority_level})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground max-w-xs truncate">
|
||||
{job.failure_category ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rpo.jobs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">No workstation jobs found</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="compliance" className="space-y-6">
|
||||
{compliance && (
|
||||
<>
|
||||
|
|
@ -174,7 +311,25 @@ export default function BackupStatusPage() {
|
|||
contractedNotBackedUp={compliance.summary.contractedNotBackedUp}
|
||||
backedUpNotContracted={compliance.summary.backedUpNotContracted}
|
||||
/>
|
||||
<Tabs defaultValue="coverage" className="space-y-4">
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="coverage" className="text-xs">Contract Coverage</TabsTrigger>
|
||||
<TabsTrigger value="mismatches" className="text-xs">
|
||||
Mismatches
|
||||
{(compliance.summary.contractedNotBackedUp + compliance.summary.backedUpNotContracted) > 0 && (
|
||||
<Badge variant="destructive" className="ml-1.5 h-4 px-1 text-[10px]">
|
||||
{compliance.summary.contractedNotBackedUp + compliance.summary.backedUpNotContracted}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="coverage">
|
||||
<ContractCoverageTable />
|
||||
</TabsContent>
|
||||
<TabsContent value="mismatches">
|
||||
<ComplianceDetailTable mismatches={compliance.mismatches} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
|
|
|||
1297
app/engagement/page.tsx
Normal file
1297
app/engagement/page.tsx
Normal file
File diff suppressed because it is too large
Load diff
648
app/engagement/profile/page.tsx
Normal file
648
app/engagement/profile/page.tsx
Normal file
|
|
@ -0,0 +1,648 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
Legend,
|
||||
CartesianGrid,
|
||||
RadarChart,
|
||||
PolarGrid,
|
||||
PolarAngleAxis,
|
||||
PolarRadiusAxis,
|
||||
Radar,
|
||||
} from 'recharts';
|
||||
import { Users, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface UserOption {
|
||||
graphUserId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
jobTitle: string | null;
|
||||
department: string | null;
|
||||
}
|
||||
|
||||
interface DayData {
|
||||
date: string;
|
||||
hoursWorked: number;
|
||||
billableHours: number;
|
||||
}
|
||||
|
||||
interface MonthData {
|
||||
month: string;
|
||||
hoursWorked: number;
|
||||
billableHours: number;
|
||||
daysWorked: number;
|
||||
teamsMessages: number;
|
||||
teamsPrivateMessages: number;
|
||||
teamsCalls: number;
|
||||
meetingsAttended: number;
|
||||
meetingsOrganized: number;
|
||||
emailsSent: number;
|
||||
emailsReceived: number;
|
||||
totalMeetings: number;
|
||||
clientMeetings: number;
|
||||
meetingDurationMinutes: number;
|
||||
zoomCalls: number;
|
||||
zoomClientCalls: number;
|
||||
}
|
||||
|
||||
interface HistoryData {
|
||||
user: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
jobTitle: string | null;
|
||||
department: string | null;
|
||||
autotaskResourceId: number | null;
|
||||
};
|
||||
daily: DayData[];
|
||||
monthly: MonthData[];
|
||||
}
|
||||
|
||||
// Heat level class names — must be full strings for Tailwind to include them
|
||||
const HEAT_CLASSES = [
|
||||
'bg-muted/50',
|
||||
'bg-emerald-100 dark:bg-emerald-950',
|
||||
'bg-emerald-300 dark:bg-emerald-800',
|
||||
'bg-emerald-500 dark:bg-emerald-600',
|
||||
'bg-emerald-700 dark:bg-emerald-400',
|
||||
];
|
||||
|
||||
function hoursLevel(h: number): number {
|
||||
if (h <= 0) return 0;
|
||||
if (h < 2) return 1;
|
||||
if (h < 5) return 2;
|
||||
if (h < 7) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
function ActivityHeatmap({ daily }: { daily: DayData[] }) {
|
||||
const dailyMap: Record<string, DayData> = {};
|
||||
for (const d of daily) dailyMap[d.date] = d;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// Start from 52 weeks ago, padded back to Monday
|
||||
const startDate = new Date(today);
|
||||
startDate.setDate(startDate.getDate() - 363);
|
||||
const dow = (startDate.getDay() + 6) % 7; // Mon=0 … Sun=6
|
||||
startDate.setDate(startDate.getDate() - dow);
|
||||
|
||||
const yearAgo = new Date(today);
|
||||
yearAgo.setFullYear(yearAgo.getFullYear() - 1);
|
||||
|
||||
// Build weeks
|
||||
const weeks: Array<Array<{ date: string; inRange: boolean }>> = [];
|
||||
const cursor = new Date(startDate);
|
||||
while (cursor <= today) {
|
||||
const week: Array<{ date: string; inRange: boolean }> = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const key = cursor.toISOString().slice(0, 10);
|
||||
week.push({ date: key, inRange: cursor >= yearAgo && cursor <= today });
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
weeks.push(week);
|
||||
}
|
||||
|
||||
// Month label: track where each month starts
|
||||
const monthLabels: Array<{ weekIndex: number; label: string }> = [];
|
||||
let lastMonth = -1;
|
||||
weeks.forEach((week, wi) => {
|
||||
const d = new Date(week[0].date + 'T00:00:00');
|
||||
const m = d.getMonth();
|
||||
if (m !== lastMonth) {
|
||||
monthLabels.push({
|
||||
weekIndex: wi,
|
||||
label: d.toLocaleDateString('en-US', { month: 'short' }),
|
||||
});
|
||||
lastMonth = m;
|
||||
}
|
||||
});
|
||||
|
||||
const DAY_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', 'Sun'];
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto pb-2">
|
||||
<div className="inline-flex gap-2 min-w-0">
|
||||
{/* Day labels */}
|
||||
<div className="flex flex-col gap-[3px] pt-5 shrink-0">
|
||||
{DAY_LABELS.map((label, i) => (
|
||||
<div key={i} className="h-[13px] flex items-center">
|
||||
<span className="text-[10px] text-muted-foreground w-6 text-right pr-1 leading-none">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="flex flex-col">
|
||||
{/* Month labels */}
|
||||
<div className="flex mb-1 h-4 relative">
|
||||
{weeks.map((_, wi) => {
|
||||
const ml = monthLabels.find(m => m.weekIndex === wi);
|
||||
return (
|
||||
<div key={wi} className="w-[13px] shrink-0 relative">
|
||||
{ml && (
|
||||
<span className="absolute text-[10px] text-muted-foreground whitespace-nowrap leading-none">
|
||||
{ml.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Cells */}
|
||||
<div className="flex gap-[3px]">
|
||||
{weeks.map((week, wi) => (
|
||||
<div key={wi} className="flex flex-col gap-[3px]">
|
||||
{week.map((cell, di) => {
|
||||
const data = dailyMap[cell.date];
|
||||
const hours = data?.hoursWorked ?? 0;
|
||||
const level = cell.inRange ? hoursLevel(hours) : 0;
|
||||
const isFuture = cell.date > today.toISOString().slice(0, 10);
|
||||
return (
|
||||
<div
|
||||
key={di}
|
||||
className={cn(
|
||||
'w-[13px] h-[13px] rounded-[2px]',
|
||||
isFuture ? 'opacity-0' : HEAT_CLASSES[level]
|
||||
)}
|
||||
title={
|
||||
cell.inRange && !isFuture
|
||||
? `${cell.date}: ${hours.toFixed(1)}h (${(data?.billableHours ?? 0).toFixed(1)}h billable)`
|
||||
: cell.date
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-1.5 mt-3 ml-8">
|
||||
<span className="text-[10px] text-muted-foreground">Less</span>
|
||||
{HEAT_CLASSES.map((cls, i) => (
|
||||
<div key={i} className={cn('w-[13px] h-[13px] rounded-[2px]', cls)} />
|
||||
))}
|
||||
<span className="text-[10px] text-muted-foreground">More</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildRadarData(monthly: MonthData[]) {
|
||||
const active = monthly.filter(m => m.hoursWorked > 0 || m.teamsMessages > 0 || m.emailsSent > 0);
|
||||
if (active.length === 0) return [];
|
||||
|
||||
const avg = (fn: (m: MonthData) => number) =>
|
||||
active.reduce((s, m) => s + fn(m), 0) / active.length;
|
||||
|
||||
const avgHours = avg(m => m.hoursWorked);
|
||||
const avgBillable = avg(m => m.billableHours);
|
||||
const avgMeetings = avg(m => m.totalMeetings);
|
||||
const avgComms = avg(m => m.teamsMessages + m.emailsSent);
|
||||
const avgCalls = avg(m => m.zoomClientCalls + m.teamsCalls);
|
||||
|
||||
return [
|
||||
{
|
||||
subject: 'Utilization',
|
||||
value: Math.min(100, Math.round((avgHours / 160) * 100)),
|
||||
fullMark: 100,
|
||||
},
|
||||
{
|
||||
subject: 'Billable %',
|
||||
value: avgHours > 0 ? Math.round((avgBillable / avgHours) * 100) : 0,
|
||||
fullMark: 100,
|
||||
},
|
||||
{
|
||||
subject: 'Meetings',
|
||||
value: Math.min(100, Math.round((avgMeetings / 25) * 100)),
|
||||
fullMark: 100,
|
||||
},
|
||||
{
|
||||
subject: 'Comms',
|
||||
value: Math.min(100, Math.round((avgComms / 400) * 100)),
|
||||
fullMark: 100,
|
||||
},
|
||||
{
|
||||
subject: 'Client Calls',
|
||||
value: Math.min(100, Math.round((avgCalls / 15) * 100)),
|
||||
fullMark: 100,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function monthLabel(m: string) {
|
||||
return new Date(m + '-02').toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
interface BackfillStatus {
|
||||
running: boolean;
|
||||
started: string | null;
|
||||
processed: number;
|
||||
total: number;
|
||||
currentUser: string | null;
|
||||
errors: number;
|
||||
done: boolean;
|
||||
log: string[];
|
||||
}
|
||||
|
||||
export default function EngagementProfilePage() {
|
||||
const [users, setUsers] = useState<UserOption[]>([]);
|
||||
const [usersLoading, setUsersLoading] = useState(true);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
const [history, setHistory] = useState<HistoryData | null>(null);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [backfill, setBackfill] = useState<BackfillStatus | null>(null);
|
||||
const [backfillStarting, setBackfillStarting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/engagement/users?period=D30&sort=display_name&order=asc');
|
||||
const data = await res.json();
|
||||
setUsers(data.users ?? []);
|
||||
} catch {}
|
||||
setUsersLoading(false);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const loadHistory = useCallback(async (userId: string) => {
|
||||
setHistoryLoading(true);
|
||||
setHistory(null);
|
||||
try {
|
||||
const res = await fetch(`/api/engagement/user/${userId}/history`);
|
||||
const data = await res.json();
|
||||
setHistory(data);
|
||||
} catch {}
|
||||
setHistoryLoading(false);
|
||||
}, []);
|
||||
|
||||
const handleSelect = (userId: string) => {
|
||||
setSelectedUserId(userId);
|
||||
loadHistory(userId);
|
||||
};
|
||||
|
||||
const startBackfill = async () => {
|
||||
setBackfillStarting(true);
|
||||
try {
|
||||
await fetch('/api/engagement/backfill-meetings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ monthsBack: 12 }),
|
||||
});
|
||||
pollBackfill();
|
||||
} catch {}
|
||||
setBackfillStarting(false);
|
||||
};
|
||||
|
||||
const pollBackfill = useCallback(async () => {
|
||||
const res = await fetch('/api/engagement/backfill-meetings').catch(() => null);
|
||||
if (!res) return;
|
||||
const data: BackfillStatus = await res.json();
|
||||
setBackfill(data);
|
||||
if (data.running) setTimeout(pollBackfill, 2000);
|
||||
else if (data.done && selectedUserId) loadHistory(selectedUserId);
|
||||
}, [selectedUserId, loadHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/engagement/backfill-meetings').then(r => r.json()).then((d: BackfillStatus) => {
|
||||
setBackfill(d);
|
||||
if (d.running) setTimeout(pollBackfill, 2000);
|
||||
}).catch(() => {});
|
||||
}, [pollBackfill]);
|
||||
|
||||
const monthly = history?.monthly ?? [];
|
||||
const radarData = buildRadarData(monthly);
|
||||
|
||||
const totalHours = monthly.reduce((s, m) => s + m.hoursWorked, 0);
|
||||
const totalBillable = monthly.reduce((s, m) => s + m.billableHours, 0);
|
||||
const billablePct = totalHours > 0 ? Math.round((totalBillable / totalHours) * 100) : 0;
|
||||
const activeMonths = monthly.filter(m => m.hoursWorked > 0).length;
|
||||
const peakMonth = monthly.reduce(
|
||||
(best, m) => (m.hoursWorked > (best?.hoursWorked ?? 0) ? m : best),
|
||||
null as MonthData | null
|
||||
);
|
||||
|
||||
const barData = monthly.map(m => ({
|
||||
name: m.month.slice(5),
|
||||
billable: parseFloat(m.billableHours.toFixed(1)),
|
||||
nonBillable: parseFloat((m.hoursWorked - m.billableHours).toFixed(1)),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-8 space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Employee Profile</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">12-month activity overview</p>
|
||||
</div>
|
||||
|
||||
<Select value={selectedUserId} onValueChange={handleSelect} disabled={usersLoading}>
|
||||
<SelectTrigger className="w-64">
|
||||
<SelectValue placeholder={usersLoading ? 'Loading…' : 'Select employee…'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{users.map(u => (
|
||||
<SelectItem key={u.graphUserId} value={u.graphUserId}>
|
||||
<div className="flex flex-col items-start">
|
||||
<span>{u.displayName}</span>
|
||||
{u.jobTitle && (
|
||||
<span className="text-xs text-muted-foreground">{u.jobTitle}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Backfill panel */}
|
||||
{backfill && (backfill.running || backfill.done) ? (
|
||||
<Card className={cn('border', backfill.running ? 'border-blue-400' : backfill.errors > 0 ? 'border-yellow-400' : 'border-green-400')}>
|
||||
<CardContent className="py-3 px-4 space-y-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
{backfill.running && <RefreshCw className="h-4 w-4 animate-spin text-blue-500" />}
|
||||
{backfill.running
|
||||
? `Backfilling meetings… ${backfill.processed}/${backfill.total} users`
|
||||
: `Backfill complete — ${backfill.processed} users, ${backfill.errors} errors`}
|
||||
</div>
|
||||
{backfill.running && backfill.currentUser && (
|
||||
<span className="text-xs text-muted-foreground truncate">{backfill.currentUser}</span>
|
||||
)}
|
||||
</div>
|
||||
{backfill.running && backfill.total > 0 && (
|
||||
<div className="w-full bg-muted rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-blue-500 h-1.5 rounded-full transition-all"
|
||||
style={{ width: `${Math.round((backfill.processed / backfill.total) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{backfill.log.length > 0 && (
|
||||
<pre className="text-[10px] text-muted-foreground max-h-24 overflow-y-auto bg-muted/30 rounded p-2">
|
||||
{backfill.log.slice(-20).join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={startBackfill}
|
||||
disabled={backfillStarting}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className={cn('h-3.5 w-3.5', backfillStarting && 'animate-spin')} />
|
||||
Backfill 12 months of meetings
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedUserId && (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<Users className="h-10 w-10 text-muted-foreground mb-4" />
|
||||
<p className="font-medium">Select an employee</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Choose an employee above to see their 12-month activity profile
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{selectedUserId && historyLoading && (
|
||||
<div className="space-y-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-48" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedUserId && !historyLoading && history && (
|
||||
<>
|
||||
{/* User header */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{history.user.displayName}</h2>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 mt-0.5 text-sm text-muted-foreground">
|
||||
{history.user.jobTitle && <span>{history.user.jobTitle}</span>}
|
||||
{history.user.jobTitle && history.user.department && <span>·</span>}
|
||||
{history.user.department && <span>{history.user.department}</span>}
|
||||
{(history.user.jobTitle || history.user.department) && <span>·</span>}
|
||||
<span>{history.user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Year stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">Total Hours</p>
|
||||
<p className="text-2xl font-bold tabular-nums">{totalHours.toFixed(0)}</p>
|
||||
<p className="text-xs text-muted-foreground">over 12 months</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">Billable Rate</p>
|
||||
<p className="text-2xl font-bold tabular-nums">{billablePct}%</p>
|
||||
<p className="text-xs text-muted-foreground">{totalBillable.toFixed(0)}h billable</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">Avg hrs / month</p>
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{activeMonths > 0 ? (totalHours / activeMonths).toFixed(0) : '—'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{activeMonths} active months</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">Peak Month</p>
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{peakMonth ? peakMonth.hoursWorked.toFixed(0) + 'h' : '—'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{peakMonth ? monthLabel(peakMonth.month) : ''}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Activity heatmap */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Activity Calendar</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">Daily hours worked — last 12 months</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{history.daily.length > 0 ? (
|
||||
<ActivityHeatmap daily={history.daily} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center">
|
||||
No time entry data available
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Monthly bar chart + radar */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Monthly Hours</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">Billable vs non-billable</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={barData} margin={{ top: 5, right: 10, left: -20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip
|
||||
formatter={(value, name) => [
|
||||
`${value}h`,
|
||||
name === 'billable' ? 'Billable' : 'Non-billable',
|
||||
]}
|
||||
contentStyle={{ fontSize: 12 }}
|
||||
/>
|
||||
<Legend
|
||||
formatter={v => (v === 'billable' ? 'Billable' : 'Non-billable')}
|
||||
wrapperStyle={{ fontSize: 11 }}
|
||||
/>
|
||||
<Bar dataKey="billable" stackId="a" fill="#10b981" name="billable" />
|
||||
<Bar dataKey="nonBillable" stackId="a" fill="#94a3b8" name="nonBillable" radius={[2, 2, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Activity Signature</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">12-month average profile</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-center pt-0">
|
||||
{radarData.length > 0 ? (
|
||||
<RadarChart width={240} height={220} data={radarData}>
|
||||
<PolarGrid />
|
||||
<PolarAngleAxis dataKey="subject" tick={{ fontSize: 10 }} />
|
||||
<PolarRadiusAxis
|
||||
angle={90}
|
||||
domain={[0, 100]}
|
||||
tick={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Radar
|
||||
name="Profile"
|
||||
dataKey="value"
|
||||
stroke="#10b981"
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
</RadarChart>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No data</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Monthly breakdown table */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Monthly Breakdown</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-muted-foreground uppercase tracking-wide">
|
||||
<th className="text-left px-4 py-2.5 font-medium">Month</th>
|
||||
<th className="text-right px-3 py-2.5 font-medium">Hours</th>
|
||||
<th className="text-right px-3 py-2.5 font-medium">Billable</th>
|
||||
<th className="text-right px-3 py-2.5 font-medium">Bill %</th>
|
||||
<th className="text-right px-3 py-2.5 font-medium hidden sm:table-cell">Days</th>
|
||||
<th className="text-right px-3 py-2.5 font-medium hidden md:table-cell">Meetings</th>
|
||||
<th className="text-right px-3 py-2.5 font-medium hidden md:table-cell">Messages</th>
|
||||
<th className="text-right px-3 py-2.5 font-medium hidden lg:table-cell">Emails</th>
|
||||
<th className="text-right px-3 py-2.5 font-medium hidden lg:table-cell">Calls</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...monthly].reverse().map(m => {
|
||||
const pct = m.hoursWorked > 0 ? Math.round((m.billableHours / m.hoursWorked) * 100) : 0;
|
||||
const isEmpty =
|
||||
m.hoursWorked === 0 && m.teamsMessages === 0 && m.emailsSent === 0;
|
||||
const totalCalls = m.zoomClientCalls + m.teamsCalls;
|
||||
return (
|
||||
<tr
|
||||
key={m.month}
|
||||
className={cn(
|
||||
'border-b last:border-0 hover:bg-muted/30 transition-colors',
|
||||
isEmpty && 'opacity-40'
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-2 font-medium">{monthLabel(m.month)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{m.hoursWorked > 0 ? m.hoursWorked.toFixed(1) : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-emerald-600 dark:text-emerald-400">
|
||||
{m.billableHours > 0 ? m.billableHours.toFixed(1) : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{m.hoursWorked > 0 ? `${pct}%` : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums hidden sm:table-cell">
|
||||
{m.daysWorked > 0 ? m.daysWorked : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums hidden md:table-cell">
|
||||
{m.totalMeetings > 0 ? m.totalMeetings : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums hidden md:table-cell">
|
||||
{m.teamsMessages > 0 ? m.teamsMessages : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums hidden lg:table-cell">
|
||||
{m.emailsSent > 0 ? m.emailsSent : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums hidden lg:table-cell">
|
||||
{totalCalls > 0 ? totalCalls : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -51,18 +51,18 @@
|
|||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.488 0.243 264.376); /* Blue */
|
||||
--primary: oklch(0.55 0.16 220); /* Logo blue */
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.696 0.17 162.48); /* Teal */
|
||||
--accent: oklch(0.55 0.16 220); /* Logo blue */
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--ring: oklch(0.55 0.16 220);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
|
|
@ -70,12 +70,12 @@
|
|||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary: oklch(0.55 0.16 220);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--sidebar-ring: oklch(0.55 0.16 220);
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
|
@ -85,31 +85,31 @@
|
|||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.65 0.22 264.376); /* Bright Blue for dark mode */
|
||||
--primary: oklch(0.62 0.17 220); /* Logo blue - bright for dark mode */
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.75 0.15 162.48); /* Bright Teal for dark mode */
|
||||
--accent-foreground: oklch(0.145 0 0);
|
||||
--accent: oklch(0.62 0.17 220); /* Logo blue */
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.65 0.22 264.376);
|
||||
--ring: oklch(0.62 0.17 220);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-2: oklch(0.62 0.17 220); /* Logo blue */
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.65 0.22 264.376);
|
||||
--sidebar-primary: oklch(0.62 0.17 220);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.65 0.22 264.376);
|
||||
--sidebar-ring: oklch(0.62 0.17 220);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -12,7 +18,7 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Search, CheckCircle2, XCircle, Loader2, ExternalLink, Package } from 'lucide-react';
|
||||
|
||||
interface ComplianceMismatch {
|
||||
id: number;
|
||||
|
|
@ -25,15 +31,224 @@ interface ComplianceMismatch {
|
|||
device_name: string;
|
||||
contract_name: string | null;
|
||||
veeam_workload_name: string | null;
|
||||
billing_covered: boolean | null;
|
||||
billing_contract_name: string | null;
|
||||
billing_contracted_qty: number | null;
|
||||
billing_contract_id: number | null;
|
||||
coverage_source: string | null;
|
||||
}
|
||||
|
||||
interface ContractService {
|
||||
id: number;
|
||||
service_id: number | null;
|
||||
display_name: string;
|
||||
description: string | null;
|
||||
unit_price: number | null;
|
||||
unit_cost: number | null;
|
||||
quantity: number | null;
|
||||
adjusted_price: number | null;
|
||||
period_label: string | null;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
}
|
||||
|
||||
interface ContractDetail {
|
||||
id: number;
|
||||
contract_name: string;
|
||||
company_name: string;
|
||||
status: number;
|
||||
contract_type: number | null;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
interface ComplianceDetailTableProps {
|
||||
mismatches: ComplianceMismatch[];
|
||||
}
|
||||
|
||||
const BACKUP_SERVICE_PATTERNS = [
|
||||
/workstation.*backup/i,
|
||||
/w\/ backup/i,
|
||||
/windows server/i,
|
||||
/server virtual/i,
|
||||
/server phys/i,
|
||||
/esxi host/i,
|
||||
/wulf 365 it complete (endpoint|server)/i,
|
||||
/wulf it complete \((server|endpoint)\)/i,
|
||||
];
|
||||
|
||||
function isBackupService(name: string): boolean {
|
||||
return BACKUP_SERVICE_PATTERNS.some((re) => re.test(name));
|
||||
}
|
||||
|
||||
function ContractCoverageModal({
|
||||
contractId,
|
||||
companyName,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
contractId: number | null;
|
||||
companyName: string | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [data, setData] = useState<{ contract: ContractDetail; services: ContractService[] } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadedId, setLoadedId] = useState<number | null>(null);
|
||||
|
||||
const load = useCallback(async (id: number) => {
|
||||
if (loadedId === id) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/data/contracts/${id}/services`);
|
||||
if (!res.ok) throw new Error('Failed to load contract details');
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
setLoadedId(id);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadedId]);
|
||||
|
||||
if (open && contractId && loadedId !== contractId && !loading) {
|
||||
load(contractId);
|
||||
}
|
||||
|
||||
const contract = data?.contract;
|
||||
const services = data?.services ?? [];
|
||||
const backupServices = services.filter((s) => isBackupService(s.display_name));
|
||||
const otherServices = services.filter((s) => !isBackupService(s.display_name));
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
Contract Coverage
|
||||
{contract && (
|
||||
<span className="text-muted-foreground font-normal text-sm ml-1">— {contract.company_name}</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 py-4">{error}</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && contract && (
|
||||
<div className="space-y-5">
|
||||
{/* Contract header */}
|
||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-semibold text-base">{contract.contract_name}</p>
|
||||
<p className="text-sm text-muted-foreground">{contract.company_name}</p>
|
||||
</div>
|
||||
<a
|
||||
href={`https://ww1.autotask.net/contracts/views/contractView.asp?contractID=${contract.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-blue-500 hover:text-blue-600 shrink-0 mt-0.5"
|
||||
>
|
||||
View in Autotask <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
|
||||
{contract.start_date && (
|
||||
<span>Start: {new Date(contract.start_date).toLocaleDateString()}</span>
|
||||
)}
|
||||
{contract.end_date && (
|
||||
<span>End: {new Date(contract.end_date).toLocaleDateString()}</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<span className={`inline-block h-1.5 w-1.5 rounded-full ${contract.status === 1 ? 'bg-green-500' : 'bg-gray-400'}`} />
|
||||
{contract.status === 1 ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backup-relevant services */}
|
||||
{backupServices.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-green-600 dark:text-green-400">Backup-Covered Services</p>
|
||||
<ServiceTable services={backupServices} highlight />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other services */}
|
||||
{otherServices.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">All Services ({services.length})</p>
|
||||
<ServiceTable services={otherServices} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{services.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No service lines found for this contract.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceTable({ services, highlight }: { services: ContractService[]; highlight?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/40">
|
||||
<TableHead className="text-xs">Service</TableHead>
|
||||
<TableHead className="text-xs w-28 text-right">Unit Price</TableHead>
|
||||
<TableHead className="text-xs w-28 text-right">Unit Cost</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{services.map((s) => (
|
||||
<TableRow key={s.id} className={highlight ? 'bg-green-500/5' : undefined}>
|
||||
<TableCell className="text-sm py-2">
|
||||
{highlight && <CheckCircle2 className="inline h-3 w-3 text-green-500 mr-1.5 shrink-0" />}
|
||||
{s.display_name}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm py-2 text-right">
|
||||
{s.unit_price != null ? `$${Number(s.unit_price).toFixed(2)}` : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm py-2 text-right text-muted-foreground">
|
||||
{s.unit_cost != null ? `$${Number(s.unit_cost).toFixed(2)}` : '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComplianceDetailTable({ mismatches }: ComplianceDetailTableProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
const [modalContractId, setModalContractId] = useState<number | null>(null);
|
||||
const [modalCompanyName, setModalCompanyName] = useState<string | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const openModal = (contractId: number, companyName: string | null) => {
|
||||
setModalContractId(contractId);
|
||||
setModalCompanyName(companyName);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const filtered = mismatches.filter((m) => {
|
||||
const matchesSearch =
|
||||
|
|
@ -82,7 +297,7 @@ export function ComplianceDetailTable({ mismatches }: ComplianceDetailTableProps
|
|||
<TableHead>Device</TableHead>
|
||||
<TableHead>Issue</TableHead>
|
||||
<TableHead>Backup UDF</TableHead>
|
||||
<TableHead>Contract</TableHead>
|
||||
<TableHead>Contract Coverage</TableHead>
|
||||
<TableHead>Veeam Workload</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
@ -114,7 +329,34 @@ export function ComplianceDetailTable({ mismatches }: ComplianceDetailTableProps
|
|||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{m.backup_type_udf || '-'}</TableCell>
|
||||
<TableCell className="text-sm">{m.contract_name || '-'}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{m.billing_covered && m.billing_contract_id ? (
|
||||
<button
|
||||
onClick={() => openModal(m.billing_contract_id!, m.company_name)}
|
||||
className="flex items-center gap-1.5 text-left hover:underline cursor-pointer group"
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||
<span className="text-green-700 dark:text-green-400 font-medium group-hover:underline">
|
||||
{m.billing_contract_name || 'Active'}
|
||||
</span>
|
||||
{m.billing_contracted_qty != null && (
|
||||
<span className="text-muted-foreground">({m.billing_contracted_qty} seats)</span>
|
||||
)}
|
||||
</button>
|
||||
) : m.billing_covered ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||
<span className="text-green-700 dark:text-green-400 font-medium">
|
||||
{m.billing_contract_name || 'Active'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<XCircle className="h-3.5 w-3.5 text-red-500 shrink-0" />
|
||||
<span className="text-red-700 dark:text-red-400">No backup contract</span>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{m.veeam_workload_name || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
|
@ -122,6 +364,13 @@ export function ComplianceDetailTable({ mismatches }: ComplianceDetailTableProps
|
|||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<ContractCoverageModal
|
||||
contractId={modalContractId}
|
||||
companyName={modalCompanyName}
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
362
components/backup/contract-coverage-table.tsx
Normal file
362
components/backup/contract-coverage-table.tsx
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Search,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Server,
|
||||
Monitor,
|
||||
Mail,
|
||||
Package,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ServiceLine {
|
||||
cs_id: number;
|
||||
contract_id: number;
|
||||
contract_name: string;
|
||||
line_name: string;
|
||||
unit_price: number | null;
|
||||
unit_cost: number | null;
|
||||
category: 'server' | 'workstation' | 'm365' | 'other';
|
||||
}
|
||||
|
||||
interface CoverageRow {
|
||||
company_id: number;
|
||||
company_name: string;
|
||||
contracted: { servers: number; workstations: number; m365: number; other: number };
|
||||
deployed: { servers: number; workstations: number; other: number };
|
||||
lines: ServiceLine[];
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
server: 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
|
||||
workstation: 'bg-purple-500/10 text-purple-600 dark:text-purple-400',
|
||||
m365: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
|
||||
other: 'bg-muted text-muted-foreground',
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
server: 'Server',
|
||||
workstation: 'Workstation',
|
||||
m365: 'M365',
|
||||
other: 'Other',
|
||||
};
|
||||
|
||||
function DeltaBadge({ contracted, deployed }: { contracted: number; deployed: number }) {
|
||||
const delta = deployed - contracted;
|
||||
if (contracted === 0 && deployed === 0) return <span className="text-muted-foreground text-xs">—</span>;
|
||||
if (delta === 0) return <span className="text-xs text-green-600 dark:text-green-400 font-medium">✓</span>;
|
||||
if (delta > 0)
|
||||
return (
|
||||
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||
+{delta}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className="text-xs font-medium text-red-600 dark:text-red-400">
|
||||
{delta}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CountCell({
|
||||
contracted,
|
||||
deployed,
|
||||
}: {
|
||||
contracted: number;
|
||||
deployed: number;
|
||||
}) {
|
||||
const delta = deployed - contracted;
|
||||
const hasData = contracted > 0 || deployed > 0;
|
||||
if (!hasData) return <span className="text-muted-foreground text-xs">—</span>;
|
||||
|
||||
const color =
|
||||
delta === 0
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: delta > 0
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: 'text-red-600 dark:text-red-400';
|
||||
|
||||
return (
|
||||
<span className={`text-sm font-medium tabular-nums ${color}`}>
|
||||
{deployed}/{contracted}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientRow({ row }: { row: CoverageRow }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const hasAnyData =
|
||||
row.contracted.servers + row.contracted.workstations + row.contracted.m365 +
|
||||
row.deployed.servers + row.deployed.workstations > 0;
|
||||
|
||||
// Group service lines by contract
|
||||
const byContract = useMemo(() => {
|
||||
const map = new Map<number, { contract_name: string; lines: ServiceLine[] }>();
|
||||
for (const l of row.lines) {
|
||||
if (!map.has(l.contract_id)) {
|
||||
map.set(l.contract_id, { contract_name: l.contract_name, lines: [] });
|
||||
}
|
||||
map.get(l.contract_id)!.lines.push(l);
|
||||
}
|
||||
return [...map.values()];
|
||||
}, [row.lines]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
className="cursor-pointer hover:bg-muted/40 group"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{/* Expand toggle + Client */}
|
||||
<TableCell className="font-medium py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate max-w-[240px]">{row.company_name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Servers deployed/contracted */}
|
||||
<TableCell className="text-center py-2.5">
|
||||
<CountCell contracted={row.contracted.servers} deployed={row.deployed.servers} />
|
||||
</TableCell>
|
||||
|
||||
{/* Workstations deployed/contracted */}
|
||||
<TableCell className="text-center py-2.5">
|
||||
<CountCell contracted={row.contracted.workstations} deployed={row.deployed.workstations} />
|
||||
</TableCell>
|
||||
|
||||
{/* M365 contracted (no Veeam deployed count for M365) */}
|
||||
<TableCell className="text-center py-2.5">
|
||||
{row.contracted.m365 > 0 ? (
|
||||
<span className="text-sm font-medium tabular-nums text-amber-600 dark:text-amber-400">
|
||||
{row.contracted.m365}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Total service lines */}
|
||||
<TableCell className="text-center py-2.5">
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{row.lines.length}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* Expanded detail rows */}
|
||||
{expanded && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={5} className="p-0 border-b">
|
||||
<div className="bg-muted/20 px-4 py-3 space-y-3">
|
||||
{byContract.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-1">No contract service lines found.</p>
|
||||
) : (
|
||||
byContract.map((contract) => {
|
||||
const backupLines = contract.lines.filter(
|
||||
(l) => l.category === 'server' || l.category === 'workstation'
|
||||
);
|
||||
if (backupLines.length === 0) return null;
|
||||
return (
|
||||
<div key={contract.contract_name} className="space-y-1.5">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
{contract.contract_name}
|
||||
</p>
|
||||
<div className="rounded border overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 border-b">
|
||||
<th className="px-3 py-1.5 text-left font-medium">Service</th>
|
||||
<th className="px-3 py-1.5 text-left font-medium w-28">Category</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{backupLines.map((line) => (
|
||||
<tr key={line.cs_id} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-3 py-1.5">
|
||||
{line.category === 'server' ? (
|
||||
<Server className="inline h-3 w-3 mr-1.5 text-blue-500 shrink-0" />
|
||||
) : (
|
||||
<Monitor className="inline h-3 w-3 mr-1.5 text-purple-500 shrink-0" />
|
||||
)}
|
||||
{line.line_name}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<span
|
||||
className={`inline-block px-1.5 py-0.5 rounded text-[11px] font-medium ${CATEGORY_COLORS[line.category]}`}
|
||||
>
|
||||
{CATEGORY_LABELS[line.category]}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContractCoverageTable() {
|
||||
const [rows, setRows] = useState<CoverageRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<'all' | 'gap' | 'over' | 'matched'>('all');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/veeam/contract-coverage')
|
||||
.then((r) => r.json())
|
||||
.then((d) => setRows(d.rows ?? []))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return rows.filter((r) => {
|
||||
const matchesSearch = r.company_name.toLowerCase().includes(search.toLowerCase());
|
||||
if (!matchesSearch) return false;
|
||||
if (filter === 'all') return true;
|
||||
|
||||
const serverDelta = r.deployed.servers - r.contracted.servers;
|
||||
const wsDelta = r.deployed.workstations - r.contracted.workstations;
|
||||
|
||||
if (filter === 'gap') return serverDelta < 0 || wsDelta < 0;
|
||||
if (filter === 'over') return serverDelta > 0 || wsDelta > 0;
|
||||
if (filter === 'matched')
|
||||
return (
|
||||
r.contracted.servers > 0 || r.contracted.workstations > 0
|
||||
? serverDelta === 0 && wsDelta === 0
|
||||
: false
|
||||
);
|
||||
return true;
|
||||
});
|
||||
}, [rows, search, filter]);
|
||||
|
||||
const FILTERS: { key: typeof filter; label: string }[] = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'gap', label: 'Under-deployed' },
|
||||
{ key: 'over', label: 'Over-deployed' },
|
||||
{ key: 'matched', label: 'Matched' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search clients..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => setFilter(f.key)}
|
||||
className={`px-3 py-1 rounded text-xs font-medium transition-colors border ${
|
||||
filter === f.key
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-transparent text-muted-foreground border-border hover:border-foreground/40'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground ml-auto">{filtered.length} clients</span>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="font-medium">Counts: deployed / contracted</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-green-500" /> Matched
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-amber-500" /> Over-deployed
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-red-500" /> Gap
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-md border">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/40">
|
||||
<TableHead className="text-xs">Client</TableHead>
|
||||
<TableHead className="text-xs text-center w-28">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Server className="h-3 w-3" /> Servers
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-xs text-center w-28">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Monitor className="h-3 w-3" /> Workstations
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-xs text-center w-24">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Mail className="h-3 w-3" /> M365
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-xs text-center w-20">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Package className="h-3 w-3" /> Lines
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-12">
|
||||
No clients match the current filter
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filtered.map((row) => <ClientRow key={row.company_id} row={row} />)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,6 +21,9 @@ import {
|
|||
Zap,
|
||||
Radio,
|
||||
Shield,
|
||||
Users,
|
||||
TrendingUp,
|
||||
Sun,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
NavigationMenu,
|
||||
|
|
@ -61,6 +64,24 @@ const navigationItems: NavItem[] = [
|
|||
icon: HardDrive,
|
||||
description: 'Veeam backup health and compliance'
|
||||
},
|
||||
{
|
||||
title: 'Engagement',
|
||||
icon: Users,
|
||||
children: [
|
||||
{
|
||||
title: 'Overview',
|
||||
href: '/engagement',
|
||||
icon: Users,
|
||||
description: 'Staff activity and engagement metrics',
|
||||
},
|
||||
{
|
||||
title: 'Employee Profile',
|
||||
href: '/engagement/profile',
|
||||
icon: TrendingUp,
|
||||
description: '12-month activity calendar and performance profile',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Admin',
|
||||
icon: Activity,
|
||||
|
|
@ -119,6 +140,12 @@ const navigationItems: NavItem[] = [
|
|||
icon: Zap,
|
||||
description: 'Automated webhook processing workflows'
|
||||
},
|
||||
{
|
||||
title: 'Morning NOC Summary',
|
||||
href: '/admin/morning-summary',
|
||||
icon: Sun,
|
||||
description: 'Daily Zabbix overnight summary posted to Teams channels via webhook'
|
||||
},
|
||||
{
|
||||
title: 'Notification Channels',
|
||||
href: '/admin/workflow/channels',
|
||||
|
|
@ -157,7 +184,7 @@ export function AppNavigation() {
|
|||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container px-6 flex h-16 items-center justify-between">
|
||||
<div className="container mx-auto px-6 flex h-16 items-center justify-between">
|
||||
{/* Logo and App Name */}
|
||||
<Link href="/" className="flex items-center space-x-3 shrink-0">
|
||||
<img
|
||||
|
|
@ -180,7 +207,7 @@ export function AppNavigation() {
|
|||
<>
|
||||
<NavigationMenuTrigger className={cn(
|
||||
"h-9 px-4 py-2",
|
||||
item.children.some(child => isActive(child.href)) && "bg-accent"
|
||||
item.children.some(child => isActive(child.href)) && "bg-primary text-primary-foreground"
|
||||
)}>
|
||||
{item.icon && <item.icon className="w-4 h-4 mr-2" />}
|
||||
{item.title}
|
||||
|
|
@ -193,8 +220,8 @@ export function AppNavigation() {
|
|||
<Link
|
||||
href={child.href || '#'}
|
||||
className={cn(
|
||||
"block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
|
||||
isActive(child.href) && "bg-accent"
|
||||
"block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-primary/10 hover:text-primary focus:bg-primary/10 focus:text-primary",
|
||||
isActive(child.href) && "bg-primary text-primary-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center text-sm font-medium leading-none">
|
||||
|
|
@ -218,7 +245,7 @@ export function AppNavigation() {
|
|||
<NavigationMenuLink className={cn(
|
||||
navigationMenuTriggerStyle(),
|
||||
"h-9",
|
||||
isActive(item.href) && "bg-accent"
|
||||
isActive(item.href) && "bg-primary text-primary-foreground"
|
||||
)}>
|
||||
{item.icon && <item.icon className="w-4 h-4 mr-2" />}
|
||||
{item.title}
|
||||
|
|
@ -255,7 +282,7 @@ interface PageHeaderProps {
|
|||
export function PageHeader({ title, description, breadcrumbs, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="border-b">
|
||||
<div className="container px-6 py-4">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
{/* Breadcrumbs */}
|
||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||
<nav className="flex items-center space-x-2 text-sm text-muted-foreground mb-2">
|
||||
|
|
|
|||
713
components/zabbix/host-manager.tsx
Normal file
713
components/zabbix/host-manager.tsx
Normal file
|
|
@ -0,0 +1,713 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
Pencil,
|
||||
Trash2,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
MinusCircle,
|
||||
Search,
|
||||
List,
|
||||
Plus,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ZabbixTag { tag: string; value: string; }
|
||||
interface ZabbixMacro { macro: string; value: string; description?: string; }
|
||||
interface ZabbixGroup { groupid: string; name: string; }
|
||||
interface ZabbixInterface { type: number; main: number; useip: number; ip: string; dns: string; port: string; }
|
||||
|
||||
interface ZabbixHostRow {
|
||||
hostid: string;
|
||||
host: string;
|
||||
name: string;
|
||||
status: string;
|
||||
description?: string;
|
||||
interfaces?: ZabbixInterface[];
|
||||
groups?: ZabbixGroup[];
|
||||
macros?: ZabbixMacro[];
|
||||
tags?: ZabbixTag[];
|
||||
rmmMatched: boolean | null;
|
||||
sourceTag: string | null;
|
||||
}
|
||||
|
||||
interface Company {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface HostManagerProps {
|
||||
companies: Company[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function primaryIp(host: ZabbixHostRow): string {
|
||||
const iface = host.interfaces?.find((i) => i.main === 1 || (i.main as any) === '1');
|
||||
return iface?.ip ?? '—';
|
||||
}
|
||||
|
||||
function tagValue(host: ZabbixHostRow, key: string): string | null {
|
||||
return host.tags?.find((t) => t.tag === key)?.value ?? null;
|
||||
}
|
||||
|
||||
function macroValue(host: ZabbixHostRow, key: string): string | null {
|
||||
return host.macros?.find((m) => m.macro === key)?.value ?? null;
|
||||
}
|
||||
|
||||
function clientLabel(host: ZabbixHostRow): string | null {
|
||||
return tagValue(host, 'client') ?? macroValue(host, '{$AUTOTASK_COMPANY_NAME}');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit Modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EditModalProps {
|
||||
host: ZabbixHostRow;
|
||||
companies: Company[];
|
||||
onClose: () => void;
|
||||
onSaved: (updated: ZabbixHostRow) => void;
|
||||
}
|
||||
|
||||
function EditModal({ host, companies, onClose, onSaved }: EditModalProps) {
|
||||
const [name, setName] = useState(host.name);
|
||||
const [ip, setIp] = useState(primaryIp(host));
|
||||
const [description, setDescription] = useState(host.description ?? '');
|
||||
const [companyId, setCompanyId] = useState<string>(() => {
|
||||
const id = macroValue(host, '{$AUTOTASK_COMPANY_ID}');
|
||||
return id ?? 'none';
|
||||
});
|
||||
const [tags, setTags] = useState<ZabbixTag[]>(() => host.tags ? [...host.tags] : []);
|
||||
const [macros, setMacros] = useState<ZabbixMacro[]>(() => host.macros ? [...host.macros] : []);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [rebuildFromClient, setRebuildFromClient] = useState(false);
|
||||
|
||||
const ipv4Valid = /^(\d{1,3}\.){3}\d{1,3}$/.test(ip);
|
||||
|
||||
const addTag = () => setTags((t) => [...t, { tag: '', value: '' }]);
|
||||
const removeTag = (i: number) => setTags((t) => t.filter((_, idx) => idx !== i));
|
||||
const updateTag = (i: number, field: 'tag' | 'value', val: string) =>
|
||||
setTags((t) => t.map((item, idx) => idx === i ? { ...item, [field]: val } : item));
|
||||
|
||||
const addMacro = () => setMacros((m) => [...m, { macro: '{$}', value: '', description: '' }]);
|
||||
const removeMacro = (i: number) => setMacros((m) => m.filter((_, idx) => idx !== i));
|
||||
const updateMacro = (i: number, field: keyof ZabbixMacro, val: string) =>
|
||||
setMacros((m) => m.map((item, idx) => idx === i ? { ...item, [field]: val } : item));
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const resp = await fetch(`/api/zabbix/hosts/${host.hostid}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
ip: ip.trim(),
|
||||
description,
|
||||
companyId: companyId && companyId !== 'none' ? Number(companyId) : null,
|
||||
tags,
|
||||
macros,
|
||||
rebuildFromClient,
|
||||
}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
toast.error(data.error ?? 'Save failed');
|
||||
return;
|
||||
}
|
||||
toast.success(`Host "${name}" saved`);
|
||||
// Return updated row (optimistic — caller will refresh)
|
||||
onSaved({ ...host, name, description, tags, macros });
|
||||
} catch (err) {
|
||||
toast.error('Save failed: ' + String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Host</DialogTitle>
|
||||
<DialogDescription className="font-mono text-xs">{host.hostid}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 py-2">
|
||||
{/* Name + IP */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Display Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>IP Address</Label>
|
||||
<Input
|
||||
value={ip}
|
||||
onChange={(e) => setIp(e.target.value)}
|
||||
className={`font-mono ${ip && !ipv4Valid ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
{ip && !ipv4Valid && <p className="text-xs text-destructive">Invalid IPv4</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Description</Label>
|
||||
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
|
||||
</div>
|
||||
|
||||
{/* Client */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Client (Autotask)</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={companyId} onValueChange={setCompanyId}>
|
||||
<SelectTrigger className="w-72">
|
||||
<SelectValue placeholder="No client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No client</SelectItem>
|
||||
{companies.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rebuildFromClient}
|
||||
onChange={(e) => setRebuildFromClient(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Rebuild macros/tags/groups from client
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Check "Rebuild" to re-run the full ISP lookup and regenerate all groups, macros, and tags
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Tags</Label>
|
||||
<Button variant="ghost" size="sm" onClick={addTag} className="gap-1 h-7 text-xs">
|
||||
<Plus className="w-3 h-3" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{tags.map((t, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="tag"
|
||||
value={t.tag}
|
||||
onChange={(e) => updateTag(i, 'tag', e.target.value)}
|
||||
className="w-36 text-sm h-8"
|
||||
/>
|
||||
<Input
|
||||
placeholder="value"
|
||||
value={t.value}
|
||||
onChange={(e) => updateTag(i, 'value', e.target.value)}
|
||||
className="flex-1 text-sm h-8"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => removeTag(i)}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{tags.length === 0 && <p className="text-xs text-muted-foreground italic">No tags</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Macros */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Macros</Label>
|
||||
<Button variant="ghost" size="sm" onClick={addMacro} className="gap-1 h-7 text-xs">
|
||||
<Plus className="w-3 h-3" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{macros.map((m, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="{$KEY}"
|
||||
value={m.macro}
|
||||
onChange={(e) => updateMacro(i, 'macro', e.target.value)}
|
||||
className="w-52 font-mono text-sm h-8"
|
||||
/>
|
||||
<Input
|
||||
placeholder="value"
|
||||
value={m.value}
|
||||
onChange={(e) => updateMacro(i, 'value', e.target.value)}
|
||||
className="flex-1 text-sm h-8"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => removeMacro(i)}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{macros.length === 0 && <p className="text-xs text-muted-foreground italic">No macros</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !name.trim() || !ipv4Valid} className="gap-2">
|
||||
{saving ? <><Loader2 className="w-4 h-4 animate-spin" /> Saving…</> : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete Confirm Dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DeleteDialogProps {
|
||||
count: number;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
deleting: boolean;
|
||||
}
|
||||
|
||||
function DeleteDialog({ count, onConfirm, onCancel, deleting }: DeleteDialogProps) {
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onCancel(); }}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete {count} host{count !== 1 ? 's' : ''}?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will permanently remove {count === 1 ? 'this host' : `these ${count} hosts`} from Zabbix. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel} disabled={deleting}>Cancel</Button>
|
||||
<Button variant="destructive" onClick={onConfirm} disabled={deleting} className="gap-2">
|
||||
{deleting ? <><Loader2 className="w-4 h-4 animate-spin" /> Deleting…</> : <><Trash2 className="w-4 h-4" /> Delete</>}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main HostManager component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function HostManager({ companies }: HostManagerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [hosts, setHosts] = useState<ZabbixHostRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// Filters
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterSource, setFilterSource] = useState<'all' | 'datto-rmm' | 'manual'>('all');
|
||||
const [filterRmm, setFilterRmm] = useState<'all' | 'matched' | 'unmatched'>('all');
|
||||
const [filterClient, setFilterClient] = useState<string>('all');
|
||||
|
||||
// Selection
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
// Edit / delete
|
||||
const [editHost, setEditHost] = useState<ZabbixHostRow | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const loadHosts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await fetch('/api/zabbix/hosts');
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error ?? 'Failed to load');
|
||||
setHosts(data.hosts ?? []);
|
||||
setLoaded(true);
|
||||
setSelected(new Set());
|
||||
} catch (err) {
|
||||
toast.error('Failed to load hosts: ' + String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleOpen = () => {
|
||||
setOpen(true);
|
||||
if (!loaded) loadHosts();
|
||||
};
|
||||
|
||||
// Filtered hosts
|
||||
const filtered = hosts.filter((h) => {
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
const ip = primaryIp(h).toLowerCase();
|
||||
if (
|
||||
!h.name.toLowerCase().includes(q) &&
|
||||
!ip.includes(q) &&
|
||||
!(clientLabel(h) ?? '').toLowerCase().includes(q)
|
||||
) return false;
|
||||
}
|
||||
if (filterSource !== 'all' && h.sourceTag !== filterSource) return false;
|
||||
if (filterRmm === 'matched' && h.rmmMatched !== true) return false;
|
||||
if (filterRmm === 'unmatched' && h.rmmMatched !== false) return false;
|
||||
if (filterClient !== 'all') {
|
||||
const cl = clientLabel(h);
|
||||
if (!cl || !cl.toLowerCase().includes(filterClient.toLowerCase())) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// All-select toggle
|
||||
const allSelected = filtered.length > 0 && filtered.every((h) => selected.has(h.hostid));
|
||||
const someSelected = filtered.some((h) => selected.has(h.hostid));
|
||||
|
||||
const toggleAll = () => {
|
||||
if (allSelected) {
|
||||
setSelected((s) => { const n = new Set(s); filtered.forEach((h) => n.delete(h.hostid)); return n; });
|
||||
} else {
|
||||
setSelected((s) => { const n = new Set(s); filtered.forEach((h) => n.add(h.hostid)); return n; });
|
||||
}
|
||||
};
|
||||
|
||||
const toggleOne = (id: string) => {
|
||||
setSelected((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
};
|
||||
|
||||
const selectedCount = selected.size;
|
||||
|
||||
// Bulk delete
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
const hostids = Array.from(selected);
|
||||
const resp = await fetch('/api/zabbix/hosts', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hostids }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error ?? 'Delete failed');
|
||||
toast.success(`Deleted ${data.deleted} host${data.deleted !== 1 ? 's' : ''}`);
|
||||
setHosts((h) => h.filter((host) => !selected.has(host.hostid)));
|
||||
setSelected(new Set());
|
||||
setShowDeleteDialog(false);
|
||||
} catch (err) {
|
||||
toast.error('Delete failed: ' + String(err));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Unique client names for filter dropdown
|
||||
const clientNames = Array.from(
|
||||
new Set(hosts.map((h) => clientLabel(h)).filter(Boolean) as string[])
|
||||
).sort();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="pb-4 cursor-pointer select-none"
|
||||
onClick={() => (open ? setOpen(false) : handleOpen())}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{open ? <ChevronDown className="w-4 h-4 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 text-muted-foreground" />}
|
||||
<List className="w-4 h-4" />
|
||||
<CardTitle className="text-base">Host Manager</CardTitle>
|
||||
{loaded && (
|
||||
<Badge variant="secondary" className="text-xs">{hosts.length}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="mt-0">
|
||||
Browse, filter, edit and delete existing Zabbix hosts
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{open && (
|
||||
<CardContent className="pt-0 space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search name, IP, client…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8 w-56 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Source filter */}
|
||||
<Select value={filterSource} onValueChange={(v) => setFilterSource(v as any)}>
|
||||
<SelectTrigger className="w-36 h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All sources</SelectItem>
|
||||
<SelectItem value="datto-rmm">datto-rmm</SelectItem>
|
||||
<SelectItem value="manual">manual</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* RMM match filter */}
|
||||
<Select value={filterRmm} onValueChange={(v) => setFilterRmm(v as any)}>
|
||||
<SelectTrigger className="w-40 h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All RMM status</SelectItem>
|
||||
<SelectItem value="matched">RMM matched</SelectItem>
|
||||
<SelectItem value="unmatched">RMM unmatched</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Client filter */}
|
||||
<Select value={filterClient} onValueChange={setFilterClient}>
|
||||
<SelectTrigger className="w-48 h-8 text-sm">
|
||||
<SelectValue placeholder="All clients" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All clients</SelectItem>
|
||||
{clientNames.map((c) => (
|
||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Bulk delete toolbar */}
|
||||
{selectedCount > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md bg-destructive/5 border border-destructive/20">
|
||||
<span className="text-sm font-medium text-destructive">{selectedCount} selected</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={loadHosts} disabled={loading} className="gap-1.5 h-8">
|
||||
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result count */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{loading ? 'Loading…' : `${filtered.length} of ${hosts.length} host${hosts.length !== 1 ? 's' : ''}`}
|
||||
{filterRmm === 'unmatched' && !loading && (
|
||||
<span className="ml-2 text-amber-600 font-medium">— {filtered.length} not matched to an RMM site</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<div className="max-h-[560px] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
ref={(el) => { if (el) (el as any).indeterminate = someSelected && !allSelected; }}
|
||||
onCheckedChange={toggleAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>IP</TableHead>
|
||||
<TableHead>Client</TableHead>
|
||||
<TableHead>ISP</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
<TableHead>RMM</TableHead>
|
||||
<TableHead>Groups</TableHead>
|
||||
<TableHead className="w-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-12 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
||||
Loading hosts…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-12 text-muted-foreground text-sm">
|
||||
No hosts match the current filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!loading && filtered.map((h) => {
|
||||
const isUnmatched = h.rmmMatched === false;
|
||||
return (
|
||||
<TableRow
|
||||
key={h.hostid}
|
||||
className={`${selected.has(h.hostid) ? 'bg-muted/40' : ''} ${isUnmatched ? 'border-l-2 border-l-amber-400 bg-amber-500/5' : ''}`}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selected.has(h.hostid)}
|
||||
onCheckedChange={() => toggleOne(h.hostid)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-sm max-w-[200px]">
|
||||
<div className="truncate" title={h.name}>{h.name}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono truncate">{h.host !== h.name ? h.host : ''}</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{primaryIp(h)}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground max-w-[160px]">
|
||||
<span className="truncate block" title={clientLabel(h) ?? undefined}>
|
||||
{clientLabel(h) ?? <span className="italic opacity-50">—</span>}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm max-w-[160px]">
|
||||
<div className="truncate" title={tagValue(h, 'isp') ?? undefined}>
|
||||
{tagValue(h, 'isp') ?? <span className="text-muted-foreground">—</span>}
|
||||
</div>
|
||||
{tagValue(h, 'asn') && (
|
||||
<div className="text-xs text-muted-foreground">{tagValue(h, 'asn')}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{h.sourceTag ? (
|
||||
<Badge variant={h.sourceTag === 'datto-rmm' ? 'secondary' : 'outline'} className="text-xs">
|
||||
{h.sourceTag}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{h.rmmMatched === true && (
|
||||
<span title="Matched to an RMM site">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
</span>
|
||||
)}
|
||||
{h.rmmMatched === false && (
|
||||
<span title="No matching RMM site found">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||
</span>
|
||||
)}
|
||||
{h.rmmMatched === null && (
|
||||
<span title="Not an RMM-sourced host">
|
||||
<MinusCircle className="w-4 h-4 text-muted-foreground/40" />
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[180px]">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(h.groups ?? []).slice(0, 3).map((g) => (
|
||||
<Badge key={g.groupid} variant="outline" className="text-xs px-1.5 py-0">
|
||||
{g.name}
|
||||
</Badge>
|
||||
))}
|
||||
{(h.groups?.length ?? 0) > 3 && (
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0">
|
||||
+{(h.groups?.length ?? 0) - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setEditHost(h)}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Edit modal */}
|
||||
{editHost && (
|
||||
<EditModal
|
||||
host={editHost}
|
||||
companies={companies}
|
||||
onClose={() => setEditHost(null)}
|
||||
onSaved={(updated) => {
|
||||
setHosts((hs) => hs.map((h) => h.hostid === updated.hostid ? updated : h));
|
||||
setEditHost(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirm */}
|
||||
{showDeleteDialog && (
|
||||
<DeleteDialog
|
||||
count={selectedCount}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setShowDeleteDialog(false)}
|
||||
deleting={deleting}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
435
dev/pulse-morning-summary-architecture.md
Normal file
435
dev/pulse-morning-summary-architecture.md
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
# Architecture: Pulse Morning NOC Summary
|
||||
|
||||
## Context
|
||||
Wulf Consulting (MSP) needs a daily morning summary sent to management showing overnight activity
|
||||
and open issues across client infrastructure. Pulse (Node.js/TypeScript) is the internal web app
|
||||
with existing API integrations to Zabbix, PSA, RMM, Veeam, Zoom, and MS Graph. It already has a
|
||||
job scheduler (bull/agenda/node-cron). Apprise is running on the monitoring server for notification routing.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌──────────────┐ schedule ┌──────────────────────┐
|
||||
│ Job Scheduler├─────────────►│ Summary Aggregator │
|
||||
│ (existing) │ 6:30 AM │ Service │
|
||||
└──────────────┘ └──────┬───────────────┘
|
||||
│ parallel queries
|
||||
┌─────────────────┼─────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Zabbix │ │ PSA │ │ Veeam │
|
||||
│ API │ │ API │ │ API │
|
||||
└────┬─────┘ └────┬─────┘ └────┬─────┘
|
||||
│ │ │
|
||||
└───────┬───────┘ │
|
||||
▼ │
|
||||
┌─────────────────┐ │
|
||||
│ Data Correlator │◄──────────────┘
|
||||
│ & Formatter │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌───────────┼───────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌────────┐ ┌─────────┐
|
||||
│ Teams │ │ Apprise│ │ Pulse │
|
||||
│ MS Graph│ │ (ntfy, │ │ DB + │
|
||||
│ Adaptive│ │ email)│ │ Widget │
|
||||
│ Card │ │ │ │ │
|
||||
└──────────┘ └────────┘ └─────────┘
|
||||
```
|
||||
|
||||
## Hybrid Notification Strategy
|
||||
|
||||
| Channel | Method | Why |
|
||||
|---------|--------|-----|
|
||||
| **Teams Adaptive Card** | Direct via MS Graph | Rich formatting, action buttons, inline rendering — can't get this through Apprise |
|
||||
| **Email (HTML)** | Via Apprise | Apprise handles SMTP config, templating is simpler for email |
|
||||
| **ntfy** | Via Apprise | Already configured, Apprise knows the topic/auth |
|
||||
| **Pulse Dashboard** | Direct DB write | Store the summary as a record, render as a widget |
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### 1. Summary Aggregator Service
|
||||
|
||||
Single service class/module: `MorningSummaryService`
|
||||
|
||||
```typescript
|
||||
interface MorningSummary {
|
||||
generatedAt: Date;
|
||||
reportWindow: { from: Date; to: Date }; // e.g. 6pm → 6:30am
|
||||
|
||||
openProblems: Problem[]; // currently active — most important section
|
||||
resolvedOvernight: Problem[]; // resolved during the window
|
||||
backupFailures: BackupJob[]; // from Veeam API
|
||||
unmatchedAlerts: Problem[]; // Zabbix problems with no PSA ticket (action needed!)
|
||||
|
||||
stats: {
|
||||
totalIncidents: number;
|
||||
resolved: number;
|
||||
stillOpen: number;
|
||||
mttrMinutes: number; // mean time to resolve (overnight only)
|
||||
clientsAffected: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface Problem {
|
||||
host: string;
|
||||
client: string; // from Zabbix host group "Clients/..."
|
||||
triggerName: string; // "Host Unreachable", "High Packet Loss", etc.
|
||||
severity: string;
|
||||
startedAt: Date;
|
||||
resolvedAt?: Date;
|
||||
duration: string;
|
||||
psaTicketId?: string; // correlated from PSA
|
||||
psaTicketUrl?: string;
|
||||
acknowledged: boolean;
|
||||
}
|
||||
|
||||
interface BackupJob {
|
||||
client: string;
|
||||
server: string;
|
||||
jobName: string;
|
||||
status: string;
|
||||
lastRun: Date;
|
||||
message?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Zabbix API Queries Needed
|
||||
|
||||
**Open problems:**
|
||||
```json
|
||||
{
|
||||
"method": "problem.get",
|
||||
"params": {
|
||||
"recent": true,
|
||||
"sortfield": ["eventid"],
|
||||
"sortorder": "DESC"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Resolved overnight:**
|
||||
```json
|
||||
{
|
||||
"method": "event.get",
|
||||
"params": {
|
||||
"source": 0,
|
||||
"object": 0,
|
||||
"value": 0,
|
||||
"time_from": "<6pm_yesterday_unix>",
|
||||
"time_to": "<6:30am_today_unix>",
|
||||
"selectHosts": ["name"],
|
||||
"selectRelatedObject": ["description"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Host → Client mapping:**
|
||||
```json
|
||||
{
|
||||
"method": "host.get",
|
||||
"params": {
|
||||
"selectHostGroups": ["name"]
|
||||
}
|
||||
}
|
||||
```
|
||||
Filter groups starting with `Clients/` to determine the client name per host.
|
||||
|
||||
**Host groups available for reference:**
|
||||
- `Clients/Kuhn's Quality Foods`, `Clients/ADM Signs`, `Clients/Seubert and Associates`, etc. (50+ clients)
|
||||
- `ISP/Comcast Cable Communications, LLC`, `ISP/AT&T Enterprises, LLC`, etc. (25+ ISPs)
|
||||
|
||||
### 3. PSA Correlation
|
||||
|
||||
For each open Zabbix problem, query the PSA for matching tickets:
|
||||
- Match on hostname or client name + date range
|
||||
- Flag any Zabbix problem that has NO corresponding PSA ticket — these are "unmatched"
|
||||
and should be highlighted as needing attention
|
||||
|
||||
### 4. Veeam Backup Failures
|
||||
|
||||
Query Veeam API for jobs that ran overnight with status != Success.
|
||||
Include in a separate section of the summary.
|
||||
|
||||
---
|
||||
|
||||
## Teams Adaptive Card Design
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "AdaptiveCard",
|
||||
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
"version": "1.4",
|
||||
"body": [
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": "☀️ Morning NOC Summary — Mar 12, 2026",
|
||||
"weight": "bolder",
|
||||
"size": "large"
|
||||
},
|
||||
{
|
||||
"type": "ColumnSet",
|
||||
"columns": [
|
||||
{
|
||||
"type": "Column",
|
||||
"width": "auto",
|
||||
"items": [
|
||||
{ "type": "TextBlock", "text": "3", "size": "extraLarge", "color": "attention", "weight": "bolder" },
|
||||
{ "type": "TextBlock", "text": "Open", "spacing": "none" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "Column",
|
||||
"width": "auto",
|
||||
"items": [
|
||||
{ "type": "TextBlock", "text": "5", "size": "extraLarge", "color": "good", "weight": "bolder" },
|
||||
{ "type": "TextBlock", "text": "Resolved", "spacing": "none" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "Column",
|
||||
"width": "auto",
|
||||
"items": [
|
||||
{ "type": "TextBlock", "text": "18m", "size": "extraLarge", "weight": "bolder" },
|
||||
{ "type": "TextBlock", "text": "Avg MTTR", "spacing": "none" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "Container",
|
||||
"style": "attention",
|
||||
"bleed": true,
|
||||
"items": [
|
||||
{ "type": "TextBlock", "text": "🔴 OPEN ISSUES", "weight": "bolder", "spacing": "small" },
|
||||
{
|
||||
"type": "FactSet",
|
||||
"facts": [
|
||||
{ "title": "Kuhn's / FW-01", "value": "Host Unreachable — 4h 12m" },
|
||||
{ "title": "ADM / SW-Core", "value": "High Packet Loss — 2h 5m" },
|
||||
{ "title": "Seubert / DC-01", "value": "⚠️ Backup Failed — 6h (no ticket!)" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "Container",
|
||||
"style": "good",
|
||||
"bleed": true,
|
||||
"items": [
|
||||
{ "type": "TextBlock", "text": "🟢 RESOLVED OVERNIGHT", "weight": "bolder", "spacing": "small" },
|
||||
{
|
||||
"type": "FactSet",
|
||||
"facts": [
|
||||
{ "title": "LWHResTest", "value": "Host Unreachable — resolved in 15m" },
|
||||
{ "title": "Brodaks / RTR-01", "value": "Slow Response — resolved in 22m" },
|
||||
{ "title": "+3 more", "value": "All auto-resolved" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{ "type": "Action.OpenUrl", "title": "Open Zabbix", "url": "https://zabbix.wulfconsulting.cloud" },
|
||||
{ "type": "Action.OpenUrl", "title": "Open Pulse", "url": "https://pulse.wulfconsulting.cloud" },
|
||||
{ "type": "Action.OpenUrl", "title": "Ack All Open", "url": "https://pulse.wulfconsulting.cloud/ack-all" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Post via MS Graph:
|
||||
```
|
||||
POST https://graph.microsoft.com/v1.0/teams/{teamId}/channels/{channelId}/messages
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"body": {
|
||||
"contentType": "html",
|
||||
"content": "<attachment id=\"card\"></attachment>"
|
||||
},
|
||||
"attachments": [{
|
||||
"id": "card",
|
||||
"contentType": "application/vnd.microsoft.card.adaptive",
|
||||
"content": "<adaptive card JSON string>"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User Preference System
|
||||
|
||||
```typescript
|
||||
interface NotificationPreferences {
|
||||
userId: string;
|
||||
morningSummary: {
|
||||
enabled: boolean;
|
||||
channels: ('teams' | 'email' | 'ntfy' | 'pulse')[];
|
||||
schedule: string; // cron expression, default "30 6 * * 1-5"
|
||||
timezone: string; // "America/New_York"
|
||||
includeBackups: boolean;
|
||||
includeResolvedDetail: boolean; // some execs just want open issues
|
||||
severityFilter: number; // minimum severity to include (default: 2/Warning)
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Store in Pulse's existing user/settings table. Expose in Pulse UI as a settings page.
|
||||
|
||||
---
|
||||
|
||||
## Job Scheduler Integration
|
||||
|
||||
Use the existing scheduler to run the aggregation:
|
||||
|
||||
```typescript
|
||||
// Register the job
|
||||
scheduler.register('morning-summary', '30 6 * * 1-5', async () => {
|
||||
const users = await getUsersWithMorningSummaryEnabled();
|
||||
|
||||
// Aggregate once (shared data)
|
||||
const summary = await morningSummaryService.aggregate();
|
||||
|
||||
// Store for Pulse dashboard widget
|
||||
await morningSummaryService.persist(summary);
|
||||
|
||||
// Deliver per user preferences
|
||||
for (const user of users) {
|
||||
const prefs = user.notificationPreferences.morningSummary;
|
||||
|
||||
if (prefs.channels.includes('teams'))
|
||||
await teamsService.postAdaptiveCard(user, summary);
|
||||
|
||||
if (prefs.channels.includes('email'))
|
||||
await appriseService.sendEmail(user, formatEmailHtml(summary));
|
||||
|
||||
if (prefs.channels.includes('ntfy'))
|
||||
await appriseService.sendNtfy(formatNtfySummary(summary));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Aggregator Pseudocode
|
||||
|
||||
```typescript
|
||||
class MorningSummaryService {
|
||||
async aggregate(): Promise<MorningSummary> {
|
||||
const now = new Date();
|
||||
const windowStart = yesterday6pm(now);
|
||||
const windowEnd = now;
|
||||
|
||||
// Run all API calls in parallel
|
||||
const [zabbixOpen, zabbixResolved, backups, hostGroups] = await Promise.all([
|
||||
this.zabbixApi.getOpenProblems(),
|
||||
this.zabbixApi.getResolvedEvents(windowStart, windowEnd),
|
||||
this.veeamApi.getOvernightJobs(windowStart, windowEnd),
|
||||
this.zabbixApi.getHostGroupMapping() // cache this, changes rarely
|
||||
]);
|
||||
|
||||
// Map hosts → client names via host groups
|
||||
const clientMap = buildClientMap(hostGroups);
|
||||
|
||||
// Correlate with PSA tickets
|
||||
const openWithTickets = await this.psaApi.correlateProblems(zabbixOpen, clientMap);
|
||||
|
||||
// Find unmatched (no PSA ticket)
|
||||
const unmatched = openWithTickets.filter(p => !p.psaTicketId);
|
||||
|
||||
// Calculate stats
|
||||
const mttr = calculateMTTR(zabbixResolved);
|
||||
|
||||
return {
|
||||
generatedAt: now,
|
||||
reportWindow: { from: windowStart, to: windowEnd },
|
||||
openProblems: openWithTickets,
|
||||
resolvedOvernight: zabbixResolved.map(e => enrichWithClient(e, clientMap)),
|
||||
backupFailures: backups.filter(b => b.status !== 'Success'),
|
||||
unmatchedAlerts: unmatched,
|
||||
stats: {
|
||||
totalIncidents: zabbixOpen.length + zabbixResolved.length,
|
||||
resolved: zabbixResolved.length,
|
||||
stillOpen: zabbixOpen.length,
|
||||
mttrMinutes: mttr,
|
||||
clientsAffected: [...new Set(openWithTickets.map(p => p.client))]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Recommendations
|
||||
|
||||
1. **Aggregate once, deliver many** — Don't re-query Zabbix/PSA/Veeam per user. Run the
|
||||
aggregation once, then fan out to each user's preferred channels.
|
||||
|
||||
2. **"Unmatched alerts" section is the killer feature** — Highlighting Zabbix problems that
|
||||
have no PSA ticket is what will make management love this. It shows gaps in the process.
|
||||
|
||||
3. **Teams adaptive card direct, everything else through Apprise** — Adaptive cards need
|
||||
the MS Graph payload format which Apprise can't produce. For simpler formats (email body,
|
||||
ntfy text), Apprise handles routing without Pulse needing SMTP config.
|
||||
|
||||
4. **Pulse dashboard widget** — Persist each summary to the DB. Show the latest on the
|
||||
Pulse home screen so anyone can check it anytime, not just at 6:30 AM.
|
||||
|
||||
5. **Weekend mode** — Consider a different schedule or suppression for weekends. The cron
|
||||
`30 6 * * 1-5` only fires Mon-Fri. But you may want a Monday morning summary that covers
|
||||
the full weekend window (Friday 6pm → Monday 6:30am).
|
||||
|
||||
6. **Escalation hint** — If any problem has been open > 4 hours with no PSA ticket and no
|
||||
acknowledgement, flag it red in the card with "Needs Attention" — gives management
|
||||
actionable signal, not just data.
|
||||
|
||||
7. **Cache the host → client mapping** — The `Clients/` host group mapping rarely changes.
|
||||
Cache it in Pulse (refresh every few hours) to avoid an API call on every summary run.
|
||||
|
||||
---
|
||||
|
||||
## Zabbix Host Group Reference
|
||||
|
||||
Your host groups are well-organized for this feature:
|
||||
|
||||
**Client groups (50+):** `Clients/Kuhn's Quality Foods`, `Clients/ADM Signs`, `Clients/Seubert and Associates`, `Clients/Brodaks`, etc.
|
||||
|
||||
**ISP groups (25+):** `ISP/Comcast`, `ISP/AT&T`, `ISP/Armstrong`, `ISP/Bigleaf`, etc.
|
||||
|
||||
The ISP grouping can be used for a future enhancement: "ISP Outage Detection" — if 3+ hosts
|
||||
on the same ISP go down simultaneously, flag it as a likely ISP outage rather than individual
|
||||
site problems.
|
||||
|
||||
---
|
||||
|
||||
## ntfy Summary Format (via Apprise)
|
||||
|
||||
For the condensed ntfy version:
|
||||
|
||||
```
|
||||
☀️ Morning Summary — Mar 12
|
||||
|
||||
🔴 3 Open
|
||||
· Host Unreachable — Kuhn's / FW-01 (4h)
|
||||
· High Packet Loss — ADM / SW-Core (2h)
|
||||
· Backup Failed — Seubert / DC-01 (6h)
|
||||
|
||||
🟢 5 Resolved overnight (avg 18m)
|
||||
|
||||
⚠️ 1 issue with no ticket
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Email HTML Format (via Apprise)
|
||||
|
||||
Use a clean responsive HTML template with:
|
||||
- Header with date and stats (open/resolved/MTTR)
|
||||
- Red-bordered table for open issues
|
||||
- Green-bordered table for resolved
|
||||
- Yellow callout box for unmatched alerts
|
||||
- Footer with links to Zabbix and Pulse
|
||||
|
||||
Keep it mobile-friendly — management reads email on phones.
|
||||
774
dev/windsurf-zabbix-development-guide.md
Normal file
774
dev/windsurf-zabbix-development-guide.md
Normal file
|
|
@ -0,0 +1,774 @@
|
|||
# Windsurf + Sonnet Development Guide — Zabbix Monitoring System
|
||||
|
||||
> **For:** AI-assisted development in Windsurf using Claude Sonnet 4.6
|
||||
> **Organization:** Wulf Consulting (MSP)
|
||||
> **Last updated:** 2026-03-11
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [System Overview](#1-system-overview)
|
||||
2. [Infrastructure & Network Topology](#2-infrastructure--network-topology)
|
||||
3. [Zabbix API Reference](#3-zabbix-api-reference)
|
||||
4. [Host Organization & Data Model](#4-host-organization--data-model)
|
||||
5. [Notification Pipeline](#5-notification-pipeline)
|
||||
6. [Trigger Naming Conventions](#6-trigger-naming-conventions)
|
||||
7. [Grafana Integration](#7-grafana-integration)
|
||||
8. [Pulse Integration Architecture](#8-pulse-integration-architecture)
|
||||
9. [Development Patterns & Gotchas](#9-development-patterns--gotchas)
|
||||
10. [API Cookbook](#10-api-cookbook)
|
||||
|
||||
---
|
||||
|
||||
## 1. System Overview
|
||||
|
||||
Wulf Consulting is an MSP managing ~46 client sites. The monitoring stack runs on a single
|
||||
Ubuntu 24.04 server (public IP: 209.166.162.245) with all services containerized in Docker.
|
||||
|
||||
### Stack Components
|
||||
|
||||
| Service | Container | Image | Purpose |
|
||||
|---------|-----------|-------|---------|
|
||||
| **Zabbix Server** | `zabbix-server` | `zabbix/zabbix-server-pgsql:alpine-7.4-latest` | Core monitoring engine |
|
||||
| **Zabbix Frontend** | `zabbix-frontend` | `zabbix/zabbix-web-nginx-pgsql:alpine-7.4-latest` | Web UI + API endpoint |
|
||||
| **PostgreSQL** | `zabbix-postgres` | `postgres:17-alpine` | Zabbix database |
|
||||
| **Grafana** | `grafana` | `grafana/grafana:latest` | Dashboards + Zabbix plugin |
|
||||
| **ntfy** | `ntfy` | `binwiederhier/ntfy:latest` | Push notification server |
|
||||
| **Apprise** | `apprise-api` | `caronc/apprise:latest` | Multi-channel notification router |
|
||||
| **Authentik** | `authentik` | `ghcr.io/goauthentik/server:2025.8.1` | SSO/identity provider |
|
||||
| **Newt** | `newt` | `fosrl/newt` | Pangolin tunnel agent |
|
||||
|
||||
### External Access
|
||||
|
||||
All services are exposed through **Pangolin** (reverse proxy/tunnel), not direct port mappings.
|
||||
|
||||
| Service | External URL |
|
||||
|---------|-------------|
|
||||
| Zabbix | `https://zabbix.wulfconsulting.cloud` |
|
||||
| Grafana | *(via Pangolin — check Pangolin config for exact URL)* |
|
||||
|
||||
SSO is handled by Authentik with SAML integration to Zabbix.
|
||||
|
||||
### Software Versions
|
||||
|
||||
- **Zabbix:** 7.4.7
|
||||
- **PostgreSQL:** 17 (Alpine)
|
||||
- **Grafana:** Latest (with `alexanderzobnin-zabbix-app` 6.2.1)
|
||||
- **Host OS:** Ubuntu 24.04.4 LTS
|
||||
|
||||
---
|
||||
|
||||
## 2. Infrastructure & Network Topology
|
||||
|
||||
### Docker Networks
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ pangolin (172.18.0.0/16) │
|
||||
│ ┌─────────────┐ ┌──────────┐ ┌──────┐ ┌───────────────┐ │
|
||||
│ │zabbix-front │ │ grafana │ │ ntfy │ │ apprise-api │ │
|
||||
│ │ 172.18.0.4 │ │172.18.0.3│ │.0.7 │ │ 172.18.0.5 │ │
|
||||
│ └──────┬──────┘ └────┬─────┘ └──────┘ └───────────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌──────┴──────┐ │ ┌──────────┐ ┌───────────┐ │
|
||||
│ │zabbix-server│ │ │authentik │ │ newt │ │
|
||||
│ │ 172.18.0.8 │ │ │172.18.0.6│ │172.18.0.2 │ │
|
||||
│ └──────┬──────┘ │ └──────────┘ └───────────┘ │
|
||||
│ │ │ │
|
||||
└─────────┼──────────────┼─────────────────────────────────────┘
|
||||
│ │
|
||||
┌─────────┼──────────────┼──────────────────────┐
|
||||
│ │ zabbix_zabbix_internal (172.19.0.0/16) │
|
||||
│ ┌──────┴──────┐ ┌────┴─────┐ ┌──────────────┐│
|
||||
│ │zabbix-server│ │ grafana │ │zabbix-frontend││
|
||||
│ │ 172.19.0.4 │ │172.19.0.3│ │ 172.19.0.5 ││
|
||||
│ └─────────────┘ └──────────┘ └──────────────┘│
|
||||
│ ┌──────────────┐│
|
||||
│ │zabbix-postgres││
|
||||
│ │ 172.19.0.2 ││
|
||||
│ └──────────────┘│
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Connectivity Facts
|
||||
|
||||
- **Zabbix API (internal):** `http://zabbix-frontend:8080/api_jsonrpc.php` — accessible from `zabbix-server`, `grafana`, and anything on `zabbix_zabbix_internal`
|
||||
- **Zabbix API (external):** `https://zabbix.wulfconsulting.cloud/api_jsonrpc.php` — via Pangolin
|
||||
- **ntfy (internal):** `http://ntfy:80` — accessible from `zabbix-server` and `zabbix-frontend` via `pangolin` network
|
||||
- **Apprise API:** `http://apprise-api:8000` (also `0.0.0.0:8000` on host)
|
||||
- **Zabbix agent port:** `10051` (mapped to host `0.0.0.0:10051`)
|
||||
- **Grafana** has NO external port mapping — accessed only through Pangolin
|
||||
|
||||
### Docker Compose Location
|
||||
|
||||
All stack definitions: `/opt/stacks/zabbix/compose.yml`
|
||||
|
||||
Environment variables: `/opt/stacks/zabbix/.env`
|
||||
|
||||
---
|
||||
|
||||
## 3. Zabbix API Reference
|
||||
|
||||
### Authentication
|
||||
|
||||
Zabbix 7.4 uses Bearer token authentication:
|
||||
|
||||
```
|
||||
Authorization: Bearer <api_token>
|
||||
```
|
||||
|
||||
API tokens are generated in Zabbix UI: **User Settings → API tokens**
|
||||
|
||||
> **Important:** The `apiinfo.version` method MUST be called WITHOUT the Authorization header.
|
||||
> All other methods require it.
|
||||
|
||||
### Base Request Format
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "<method_name>",
|
||||
"params": { ... },
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
### API Endpoint
|
||||
|
||||
| Context | URL |
|
||||
|---------|-----|
|
||||
| From `zabbix-server` or `grafana` container | `http://zabbix-frontend:8080/api_jsonrpc.php` |
|
||||
| From the Docker host | `docker exec zabbix-frontend curl -s -X POST http://localhost:8080/api_jsonrpc.php ...` |
|
||||
| From external / Pulse | `https://zabbix.wulfconsulting.cloud/api_jsonrpc.php` |
|
||||
|
||||
### Key API Methods Used
|
||||
|
||||
| Method | Purpose | Notes |
|
||||
|--------|---------|-------|
|
||||
| `host.get` | List hosts, get groups/templates | Use `selectHostGroups`, `selectParentTemplates` |
|
||||
| `hostgroup.get` | List host groups | Filter by `Clients/` or `ISP/` prefix |
|
||||
| `trigger.get` | Get triggers, active problems | `only_true: true` for currently-firing |
|
||||
| `problem.get` | Get current problems | `recent: true` for unresolved |
|
||||
| `event.get` | Get events (problems + recoveries) | Use `time_from`/`time_to`, `value: 0` for recovery |
|
||||
| `mediatype.get` | Get notification media types | `selectMessageTemplates` for templates |
|
||||
| `mediatype.update` | Update webhook scripts/templates | Include full `script`, `parameters`, `message_templates` |
|
||||
| `action.get` | Get trigger actions | `selectOperations`, `selectRecoveryOperations` |
|
||||
| `action.update` | Update actions | Add `recovery_operations` |
|
||||
| `trigger.update` | Rename triggers, update descriptions | Use `description` (name) and `comments` (description text) |
|
||||
| `user.get` | Get users and their media | `selectMedias` for notification channels |
|
||||
| `template.get` | Get templates and their triggers | `selectTriggers` |
|
||||
|
||||
### Zabbix API Quirks (Zabbix 7.4)
|
||||
|
||||
- **Trigger `description` = trigger name** (not the description text). The description text is in `comments`.
|
||||
- **Status codes:** `0` = enabled, `1` = disabled for both media types and actions.
|
||||
- **`templateid` on host triggers:** If `0`, the trigger was created directly on the host (not inherited from a template). Non-zero = the parent trigger ID on the template.
|
||||
- **Severity levels:** `0`=Not classified, `1`=Information, `2`=Warning, `3`=Average, `4`=High, `5`=Disaster
|
||||
- **`{EVENT.VALUE}`:** `1` = problem, `0` = OK/recovery
|
||||
- **`{EVENT.NSEVERITY}`:** Numeric severity (0-5)
|
||||
- **Webhook scripts** run in Zabbix's built-in Duktape JavaScript engine (ES5 only — no `let`, `const`, arrow functions, template literals, `Array.find`, etc.)
|
||||
|
||||
---
|
||||
|
||||
## 4. Host Organization & Data Model
|
||||
|
||||
### Host Group Hierarchy
|
||||
|
||||
Every monitored host is assigned to multiple groups:
|
||||
|
||||
```
|
||||
Host: "Kuhn's Quality Foods"
|
||||
├── Clients/Kuhn's Quality Foods ← client identity
|
||||
├── ISP/Zito Media, L.P. ← internet provider
|
||||
└── Datto RMM Sites ← RMM platform category
|
||||
```
|
||||
|
||||
This triple-grouping enables:
|
||||
- **Per-client dashboards** — filter by `Clients/` group
|
||||
- **ISP outage detection** — if 3+ hosts on same ISP go down = likely ISP issue
|
||||
- **RMM correlation** — cross-reference Zabbix with Datto RMM agent status
|
||||
|
||||
### Client Groups (46)
|
||||
|
||||
| ID | Name |
|
||||
|----|------|
|
||||
| 84 | Clients/3 Rivers Express |
|
||||
| 90 | Clients/ABC Fire Extinguisher Inc |
|
||||
| 49 | Clients/ADM Signs |
|
||||
| 40 | Clients/Advanced Masonry |
|
||||
| 50 | Clients/Alabek Commercial Roofing Corp. |
|
||||
| 33 | Clients/All Saints Catholic Church |
|
||||
| 51 | Clients/Attica Hub/Seneca Publishing |
|
||||
| 42 | Clients/Bella Diamond LLC |
|
||||
| 28 | Clients/Blake Dentistry |
|
||||
| 85 | Clients/Bosak Eyecare & Optical |
|
||||
| 55 | Clients/Bridges Health Partners Services, LLC |
|
||||
| 37 | Clients/Brodaks |
|
||||
| 45 | Clients/Broker's Settlement Services, Inc. |
|
||||
| 43 | Clients/Brooks Diamonds |
|
||||
| 57 | Clients/Buffalo Glass Block |
|
||||
| 60 | Clients/CUMI America |
|
||||
| 61 | Clients/Chartiers Animal Hospital Ltd |
|
||||
| 58 | Clients/Cincinnati Glass Block |
|
||||
| 56 | Clients/Clista Electric Inc. |
|
||||
| 35 | Clients/ConnecTel, Inc. |
|
||||
| 26 | Clients/Finn Chiropractic Group |
|
||||
| 59 | Clients/Frew Plumbing, Heating, & Air |
|
||||
| 53 | Clients/Greco Gas |
|
||||
| 65 | Clients/Heart Prints Center for Early Education |
|
||||
| 47 | Clients/Hergenroeder, Rega, Ewing, & Kennedy, LLC |
|
||||
| 69 | Clients/Hynes Industries |
|
||||
| 48 | Clients/Insurance Restoration Consultants, Inc. |
|
||||
| 32 | Clients/Kuhn's Quality Foods |
|
||||
| 44 | Clients/Loss Prevention Services |
|
||||
| 80 | Clients/MDS Energy Development, LLC |
|
||||
| 88 | Clients/Marsico Financial Group, LLC |
|
||||
| 62 | Clients/Nordmann Roofing |
|
||||
| 68 | Clients/North Eastern Uniforms & Equipment Inc |
|
||||
| 38 | Clients/POH+W Architects |
|
||||
| 75 | Clients/Penn Energy Resources |
|
||||
| 86 | Clients/Pittsburgh Financial Consultants |
|
||||
| 87 | Clients/Premier Automation Holdings, Inc. |
|
||||
| 23 | Clients/Seubert and Associates |
|
||||
| 91 | Clients/Superior Distributing Co |
|
||||
| 30 | Clients/TK Plastics Company, Inc. |
|
||||
| 70 | Clients/Thoroughbred Construction Group |
|
||||
| 66 | Clients/Thrasher Group, Inc. |
|
||||
| 71 | Clients/Universal Plastics |
|
||||
| 73 | Clients/Universal Plastics Latrobe |
|
||||
| 54 | Clients/V-Systems |
|
||||
| 76 | Clients/Vorteq Coil Finishers |
|
||||
|
||||
### ISP Groups (24)
|
||||
|
||||
| ID | Name |
|
||||
|----|------|
|
||||
| 25 | ISP/AT&T Enterprises, LLC |
|
||||
| 27 | ISP/Armstrong |
|
||||
| 24 | ISP/Bigleaf Networks, Inc. |
|
||||
| 34 | ISP/Buckeye Cablevision, Inc. |
|
||||
| 52 | ISP/Charter Communications Inc |
|
||||
| 74 | ISP/Citizens Telecommunication Technologies, Inc |
|
||||
| 67 | ISP/CityNet |
|
||||
| 31 | ISP/Comcast Cable Communications, LLC |
|
||||
| 46 | ISP/DQE Communications LLC |
|
||||
| 63 | ISP/Expedient |
|
||||
| 77 | ISP/Fidium |
|
||||
| 89 | ISP/Frontier Communications of America, Inc. |
|
||||
| 82 | ISP/GeoLinks |
|
||||
| 78 | ISP/JACKSON ENERGY AUTHORITY |
|
||||
| 39 | ISP/Level 3 Parent, LLC |
|
||||
| 92 | ISP/Metalink Technologies, Inc. |
|
||||
| 72 | ISP/OneCleveland |
|
||||
| 64 | ISP/Space Exploration Technologies Corporation |
|
||||
| 83 | ISP/UPMC |
|
||||
| 79 | ISP/Ultimate Internet Access, Inc |
|
||||
| 36 | ISP/Verizon Business |
|
||||
| 81 | ISP/Wave Broadband |
|
||||
| 41 | ISP/Windstream Communications LLC |
|
||||
| 29 | ISP/Zito Media, L.P. |
|
||||
|
||||
### Other Groups
|
||||
|
||||
| ID | Name | Purpose |
|
||||
|----|------|---------|
|
||||
| 19 | Applications | Application-level monitoring |
|
||||
| 20 | Databases | Database servers |
|
||||
| 22 | Datto RMM Sites | Hosts also managed by Datto RMM |
|
||||
| 5 | Discovered hosts | Auto-discovered hosts |
|
||||
| 7 | Hypervisors | Virtualization hosts |
|
||||
| 2 | Linux servers | Linux-based systems |
|
||||
| 93 | Studio Imagine | Internal project |
|
||||
| 6 | Virtual machines | VMs |
|
||||
| 4 | Zabbix servers | Zabbix infrastructure |
|
||||
|
||||
### Host Pattern
|
||||
|
||||
All client-site hosts currently use the **ICMP Ping** template (templateid: 10564) for
|
||||
basic up/down monitoring. A typical host:
|
||||
|
||||
```
|
||||
Name: "Kuhn's Quality Foods"
|
||||
Status: enabled
|
||||
Groups: [Clients/Kuhn's Quality Foods, ISP/Zito Media L.P., Datto RMM Sites]
|
||||
Templates: [ICMP Ping]
|
||||
```
|
||||
|
||||
**77 total hosts** across the system (including Zabbix infrastructure hosts).
|
||||
|
||||
### Global Macros
|
||||
|
||||
| Macro | Value |
|
||||
|-------|-------|
|
||||
| `{$SNMP_COMMUNITY}` | `public` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Notification Pipeline
|
||||
|
||||
### Current Architecture
|
||||
|
||||
```
|
||||
Zabbix Trigger Fires
|
||||
│
|
||||
▼
|
||||
Action: "Send ntfy to NOC" (actionid: 7)
|
||||
├── Problem → ntfy webhook (mediatypeid: 102)
|
||||
└── Recovery → ntfy webhook (same media type, different template)
|
||||
│
|
||||
▼
|
||||
ntfy Webhook Script (Duktape JS)
|
||||
├── Determines problem vs recovery (EVENT.VALUE)
|
||||
├── Sets 🔴 red_circle (problem) or 🟢 green_circle (recovery)
|
||||
├── Sets ntfy priority (high for problems, low for recovery)
|
||||
├── Adds Click URL → Zabbix event page
|
||||
├── For problems: queries Zabbix API for other active issues on same client
|
||||
└── POSTs to ntfy → http://ntfy/noc-alerts
|
||||
│
|
||||
▼
|
||||
ntfy Push Notification → user's phone
|
||||
```
|
||||
|
||||
### Action Configuration
|
||||
|
||||
**Action:** "Send ntfy to NOC" (actionid: 7)
|
||||
- **Trigger condition:** Severity >= High (conditiontype 4, operator 5, value 4)
|
||||
- **Operations:** Send message to user group "Executive" (usrgrpid: 14)
|
||||
- **Recovery operations:** Notify all involved (operationtype: 11)
|
||||
- **Uses default messages** (default_msg: 1) — pulls from media type templates
|
||||
|
||||
### Users & Media
|
||||
|
||||
| User | Role | ntfy Media |
|
||||
|------|------|------------|
|
||||
| Lorentz Hinrichsen (`lorentz@wulfconsulting.com`) | Super admin (3) | `noc-alerts` topic (enabled) |
|
||||
| Tom Carlin (`tom@wulfconsulting.com`) | Super admin (3) | *(removed — was duplicate to same topic)* |
|
||||
| Admin | Super admin (3) | *(none)* |
|
||||
| guest | Guest (4) | *(none)* |
|
||||
|
||||
### ntfy Webhook Script (Current)
|
||||
|
||||
The webhook script runs inside Zabbix Server's Duktape JS engine. It:
|
||||
|
||||
1. Parses parameters from Zabbix macros
|
||||
2. Determines if this is a problem or recovery event
|
||||
3. For problems: makes 2 internal Zabbix API calls to find related active issues for the same client
|
||||
4. Formats and sends the ntfy notification with appropriate tags, priority, and click URL
|
||||
|
||||
**Full script and parameters** are documented in `/opt/stacks/zabbix/notification-changes.md`
|
||||
|
||||
### Message Templates (Current)
|
||||
|
||||
| Event | ntfy Tag | Subject | Body |
|
||||
|-------|----------|---------|------|
|
||||
| Problem | 🔴 `red_circle` | `{HOST.NAME} — {EVENT.NAME}` | Trigger description + started time + duration + related issues |
|
||||
| Recovery | 🟢 `green_circle` | `Resolved: {HOST.NAME} — {EVENT.NAME}` | "Host is back online." + downtime + restored time |
|
||||
| Update | *(inherits)* | `Updated: {HOST.NAME} — {EVENT.NAME}` | Who updated + action + status |
|
||||
|
||||
### Notification Example
|
||||
|
||||
**Problem:**
|
||||
```
|
||||
🔴 Kuhn's Quality Foods — Host Unreachable
|
||||
|
||||
Host failed to respond to 3 consecutive ICMP ping
|
||||
requests. The site may be offline, the network path
|
||||
disrupted, or the device powered off.
|
||||
|
||||
Started: 2026.03.11 at 14:22:10
|
||||
Duration: 5m 30s
|
||||
|
||||
── Other active issues (Kuhn's Quality Foods) ──
|
||||
· High Packet Loss — Kuhn's SW-Core
|
||||
```
|
||||
|
||||
**Recovery:**
|
||||
```
|
||||
🟢 Resolved: Kuhn's Quality Foods — Host Unreachable
|
||||
|
||||
Host is back online.
|
||||
|
||||
Downtime: 22m 15s
|
||||
Restored: 2026.03.11 at 14:44:25
|
||||
```
|
||||
|
||||
### Available but Disabled Media Types
|
||||
|
||||
These exist in Zabbix but are disabled. Can be enabled if needed:
|
||||
- Email, Email (HTML), Gmail, Office365, SMS
|
||||
- Discord, Slack, MS Teams, MS Teams Workflow, Telegram
|
||||
- Jira, Jira Service Management, ServiceNow, Zendesk, PagerDuty, Opsgenie
|
||||
- Many others (40+ webhook integrations available)
|
||||
|
||||
---
|
||||
|
||||
## 6. Trigger Naming Conventions
|
||||
|
||||
### Design Principles (Established 2026-03-11)
|
||||
|
||||
Triggers were renamed from Zabbix defaults to be **executive-friendly**:
|
||||
|
||||
| Default Zabbix Name | Current Name | Severity |
|
||||
|---------------------|-------------|----------|
|
||||
| `ICMP Ping: Unavailable by ICMP ping` | **Host Unreachable** | High (4) |
|
||||
| `ICMP Ping: High ICMP ping loss` | **High Packet Loss** | Warning (2) |
|
||||
| `ICMP Ping: High ICMP ping response time` | **Slow Response Time** | Warning (2) |
|
||||
|
||||
### Naming Rules
|
||||
|
||||
1. **No technical jargon in trigger names** — use impact-based language
|
||||
2. **Trigger descriptions (comments field)** contain the technical detail: what the check does, thresholds, possible causes
|
||||
3. **Keep names short** — they appear in ntfy subjects, Teams cards, dashboards
|
||||
4. **No host name in trigger name** — `{HOST.NAME}` is added by the notification template
|
||||
|
||||
### Cisco Triggers (Not Yet Renamed)
|
||||
|
||||
The Cisco Catalyst SNMP templates have their own ICMP triggers that still use the old naming:
|
||||
- `Cisco Catalyst 3750V2-24FS: Unavailable by ICMP ping`
|
||||
- `Cisco Catalyst 3750V2-24FS: High ICMP ping loss`
|
||||
- etc.
|
||||
|
||||
These are on separate templates (not the "ICMP Ping" template) and have `templateid: 0` on host triggers. Renaming these requires updating each Cisco template individually.
|
||||
|
||||
---
|
||||
|
||||
## 7. Grafana Integration
|
||||
|
||||
### Plugin
|
||||
|
||||
- **alexanderzobnin-zabbix-app** v6.2.1 — connects directly to Zabbix API
|
||||
- Datasource URL (internal): `http://zabbix-frontend:8080/api_jsonrpc.php`
|
||||
|
||||
### Planned: MSP Executive Status Board
|
||||
|
||||
Design goal: at-a-glance dashboard for management showing:
|
||||
|
||||
| Panel | Type | Data Source |
|
||||
|-------|------|-------------|
|
||||
| Current problems by severity | Stat panels | Zabbix problems |
|
||||
| Problems per client | Bar gauge | Zabbix trigger groups |
|
||||
| Active problem list | Table | Zabbix problems |
|
||||
| SLA/uptime per client | Zabbix SLA panel | Zabbix SLA |
|
||||
| Host status grid | Status map plugin | Zabbix hosts |
|
||||
|
||||
Filter by `Clients/` host groups. Use Grafana variables for client selection dropdown.
|
||||
|
||||
---
|
||||
|
||||
## 8. Pulse Integration Architecture
|
||||
|
||||
### Overview
|
||||
|
||||
**Pulse** is the internal Node.js/TypeScript web app with API access to:
|
||||
- Zabbix (this system)
|
||||
- PSA (ticketing)
|
||||
- Datto RMM
|
||||
- Veeam (backups)
|
||||
- Microsoft Graph (Teams, email)
|
||||
- Zoom
|
||||
- Apprise (notification routing)
|
||||
|
||||
### Planned: Morning NOC Summary
|
||||
|
||||
Full architecture document: `/opt/stacks/zabbix/pulse-morning-summary-architecture.md`
|
||||
|
||||
**Key points:**
|
||||
- Scheduled job at 6:30 AM weekdays
|
||||
- Aggregates data from Zabbix + PSA + Veeam in parallel
|
||||
- Sends Teams adaptive card (direct via MS Graph), email/ntfy (via Apprise)
|
||||
- User-configurable delivery preferences
|
||||
- Killer feature: **unmatched alerts** — Zabbix problems with no PSA ticket
|
||||
|
||||
### Zabbix API Queries for Pulse
|
||||
|
||||
**Get all open problems with host/client context:**
|
||||
```typescript
|
||||
// 1. Get open problems
|
||||
const problems = await zabbixApi('problem.get', {
|
||||
recent: true,
|
||||
sortfield: ['eventid'],
|
||||
sortorder: 'DESC'
|
||||
});
|
||||
|
||||
// 2. Get host → client mapping (cache this)
|
||||
const hosts = await zabbixApi('host.get', {
|
||||
output: ['hostid', 'host', 'name'],
|
||||
selectHostGroups: ['groupid', 'name']
|
||||
});
|
||||
|
||||
// 3. Build client map
|
||||
const clientMap = {};
|
||||
for (const host of hosts) {
|
||||
const clientGroup = host.hostgroups.find(g => g.name.startsWith('Clients/'));
|
||||
if (clientGroup) {
|
||||
clientMap[host.hostid] = clientGroup.name.replace('Clients/', '');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Get overnight resolved events:**
|
||||
```typescript
|
||||
const resolved = await zabbixApi('event.get', {
|
||||
source: 0,
|
||||
object: 0,
|
||||
value: 0, // recovery events only
|
||||
time_from: Math.floor(yesterday6pm.getTime() / 1000),
|
||||
time_to: Math.floor(today630am.getTime() / 1000),
|
||||
selectHosts: ['name'],
|
||||
selectRelatedObject: ['description'],
|
||||
sortfield: ['clock'],
|
||||
sortorder: 'DESC'
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Development Patterns & Gotchas
|
||||
|
||||
### Zabbix Webhook Script Constraints
|
||||
|
||||
The webhook script runs in **Duktape** (ES5 JavaScript engine):
|
||||
|
||||
**DO:**
|
||||
```javascript
|
||||
var x = 'hello'; // var only
|
||||
for (var i = 0; i < arr.length; i++) { } // classic for loops
|
||||
JSON.parse(), JSON.stringify() // available
|
||||
new HttpRequest() // Zabbix's HTTP client
|
||||
btoa() // Base64 encoding available
|
||||
```
|
||||
|
||||
**DON'T:**
|
||||
```javascript
|
||||
let x = 'hello'; // NO let/const
|
||||
const y = () => {}; // NO arrow functions
|
||||
`template ${literal}`; // NO template literals
|
||||
arr.find(x => x.id === 1); // NO Array.find/includes/map
|
||||
for (const x of arr) {} // NO for...of
|
||||
```
|
||||
|
||||
### HttpRequest in Webhooks
|
||||
|
||||
```javascript
|
||||
var req = new HttpRequest();
|
||||
req.addHeader('Content-Type: application/json');
|
||||
req.addHeader('Authorization: Bearer TOKEN');
|
||||
|
||||
var resp = req.post(url, body); // POST
|
||||
var resp = req.put(url, body); // PUT
|
||||
var resp = req.get(url); // GET
|
||||
|
||||
var status = req.getStatus(); // HTTP status code
|
||||
```
|
||||
|
||||
Multiple HttpRequest instances CAN be created in the same script (used for the related-problems enrichment that calls Zabbix API before calling ntfy).
|
||||
|
||||
### API Update Patterns
|
||||
|
||||
When updating a media type, you must include the FULL array for `parameters` and `message_templates` — they're **replaced entirely**, not merged.
|
||||
|
||||
```python
|
||||
# WRONG — this deletes all other parameters
|
||||
mediatype.update({ parameters: [{"name": "new_param", "value": "x"}] })
|
||||
|
||||
# RIGHT — include ALL parameters
|
||||
mediatype.update({ parameters: [
|
||||
{"name": "endpoint", "value": "http://ntfy/noc-alerts"},
|
||||
{"name": "username", "value": "monitoring"},
|
||||
# ... all existing params ...
|
||||
{"name": "new_param", "value": "x"}
|
||||
] })
|
||||
```
|
||||
|
||||
### Trigger Description vs Comments
|
||||
|
||||
This is confusing in the Zabbix API:
|
||||
- `description` field = **the trigger name** (what you see in the UI)
|
||||
- `comments` field = **the description text** (the explanation paragraph)
|
||||
|
||||
```json
|
||||
{
|
||||
"triggerid": "23176",
|
||||
"description": "Host Unreachable", // ← this is the NAME
|
||||
"comments": "Host failed to respond to..." // ← this is the DESCRIPTION
|
||||
}
|
||||
```
|
||||
|
||||
### Host Group Filtering Pattern
|
||||
|
||||
To get the client name for a host:
|
||||
|
||||
```javascript
|
||||
// Get host groups
|
||||
var groups = hostData.hostgroups;
|
||||
var clientName = null;
|
||||
for (var i = 0; i < groups.length; i++) {
|
||||
if (groups[i].name.indexOf("Clients/") === 0) {
|
||||
clientName = groups[i].name.replace("Clients/", "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
// clientName = "Kuhn's Quality Foods"
|
||||
```
|
||||
|
||||
### Docker Exec for API Calls
|
||||
|
||||
Since the Zabbix frontend doesn't have ports mapped to the host, API calls from the host must go through `docker exec`:
|
||||
|
||||
```bash
|
||||
docker exec zabbix-frontend curl -s -X POST \
|
||||
"http://localhost:8080/api_jsonrpc.php" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{"jsonrpc":"2.0","method":"...","params":{...},"id":1}'
|
||||
```
|
||||
|
||||
Or use Python for complex payloads (escaping JSON in bash is fragile):
|
||||
|
||||
```python
|
||||
import json, subprocess
|
||||
|
||||
payload = json.dumps({...})
|
||||
result = subprocess.run(
|
||||
["docker", "exec", "zabbix-frontend", "curl", "-s", "-X", "POST",
|
||||
"http://localhost:8080/api_jsonrpc.php",
|
||||
"-H", "Content-Type: application/json",
|
||||
"-H", f"Authorization: Bearer {TOKEN}",
|
||||
"-d", payload],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
```
|
||||
|
||||
### External API Access (from Pulse or other servers)
|
||||
|
||||
```typescript
|
||||
const response = await fetch('https://zabbix.wulfconsulting.cloud/api_jsonrpc.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${ZABBIX_API_TOKEN}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'problem.get',
|
||||
params: { recent: true },
|
||||
id: 1
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. API Cookbook
|
||||
|
||||
### Get All Active Problems
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "problem.get",
|
||||
"params": {
|
||||
"output": "extend",
|
||||
"recent": true,
|
||||
"sortfield": ["eventid"],
|
||||
"sortorder": "DESC"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Get Active Problems for a Specific Client
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "trigger.get",
|
||||
"params": {
|
||||
"output": ["triggerid", "description", "lastchange", "priority"],
|
||||
"groupids": ["32"],
|
||||
"only_true": true,
|
||||
"selectHosts": ["name"],
|
||||
"skipDependent": true
|
||||
}
|
||||
}
|
||||
```
|
||||
*(groupid 32 = Clients/Kuhn's Quality Foods)*
|
||||
|
||||
### Get All Hosts with Client + ISP Mapping
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "host.get",
|
||||
"params": {
|
||||
"output": ["hostid", "host", "name", "status"],
|
||||
"selectHostGroups": ["groupid", "name"],
|
||||
"selectParentTemplates": ["templateid", "name"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Get Recovery Events in a Time Window
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "event.get",
|
||||
"params": {
|
||||
"source": 0,
|
||||
"object": 0,
|
||||
"value": 0,
|
||||
"time_from": 1741647600,
|
||||
"time_to": 1741692600,
|
||||
"selectHosts": ["name"],
|
||||
"selectRelatedObject": ["description"],
|
||||
"sortfield": ["clock"],
|
||||
"sortorder": "DESC"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Update a Trigger Name + Description
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "trigger.update",
|
||||
"params": {
|
||||
"triggerid": "23176",
|
||||
"description": "Host Unreachable",
|
||||
"comments": "Host failed to respond to 3 consecutive ICMP ping requests."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Get Media Type with Full Details
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "mediatype.get",
|
||||
"params": {
|
||||
"output": "extend",
|
||||
"selectMessageTemplates": "extend",
|
||||
"mediatypeids": ["102"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Test ntfy Notification Manually
|
||||
|
||||
```bash
|
||||
docker exec zabbix-frontend curl -s -X PUT \
|
||||
-H "Authorization: Basic $(echo -n 'monitoring:GBigt1231#' | base64)" \
|
||||
-H "Title: Test Notification" \
|
||||
-H "Priority: 3" \
|
||||
-H "Tags: red_circle" \
|
||||
"http://ntfy/noc-alerts" \
|
||||
-d "This is a test notification from Zabbix"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **Notification changes (before/after + restoration):** `/opt/stacks/zabbix/notification-changes.md`
|
||||
- **Pulse morning summary architecture:** `/opt/stacks/zabbix/pulse-morning-summary-architecture.md`
|
||||
- **Docker Compose:** `/opt/stacks/zabbix/compose.yml`
|
||||
- **Zabbix SSO setup:** `/opt/stacks/zabbix/ENTRA-AUTHENTIK-ZABBIX-SSO.md`
|
||||
- **Zabbix 7.4 API docs:** https://www.zabbix.com/documentation/7.4/en/manual/api
|
||||
424
lib/services/engagement-sync-service.ts
Normal file
424
lib/services/engagement-sync-service.ts
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
/**
|
||||
* Engagement Sync Service
|
||||
* Orchestrates Microsoft Graph → PostgreSQL sync for employee engagement data
|
||||
*/
|
||||
|
||||
import { getMsgraphClient } from './msgraph-factory';
|
||||
import type { CalendarEvent, UserMessage } from './msgraph-client';
|
||||
import { postgresClient } from './postgres-client';
|
||||
|
||||
const PERIODS = ['D7', 'D30', 'D90'] as const;
|
||||
type Period = typeof PERIODS[number];
|
||||
|
||||
const PERIOD_DAYS: Record<Period, number> = { D7: 7, D30: 30, D90: 90 };
|
||||
|
||||
interface CalendarBucket {
|
||||
meetingCount: number;
|
||||
durationSeconds: number;
|
||||
externalCount: number;
|
||||
}
|
||||
|
||||
function computeCalendarBuckets(
|
||||
events: CalendarEvent[],
|
||||
userEmail: string,
|
||||
internalDomains: Set<string>,
|
||||
now: Date
|
||||
): Record<Period, CalendarBucket> {
|
||||
const cutoffs: Record<Period, number> = {
|
||||
D7: now.getTime() - 7 * 24 * 60 * 60 * 1000,
|
||||
D30: now.getTime() - 30 * 24 * 60 * 60 * 1000,
|
||||
D90: now.getTime() - 90 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
const result: Record<Period, CalendarBucket> = {
|
||||
D7: { meetingCount: 0, durationSeconds: 0, externalCount: 0 },
|
||||
D30: { meetingCount: 0, durationSeconds: 0, externalCount: 0 },
|
||||
D90: { meetingCount: 0, durationSeconds: 0, externalCount: 0 },
|
||||
};
|
||||
|
||||
for (const event of events) {
|
||||
const eventTime = new Date(event.start.dateTime).getTime();
|
||||
const durationSec = Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
(new Date(event.end.dateTime).getTime() - new Date(event.start.dateTime).getTime()) / 1000
|
||||
)
|
||||
);
|
||||
|
||||
// An attendee is external if their domain is not in the org's verified domains
|
||||
// (filter out the user themselves to avoid false positives)
|
||||
const hasExternal = event.attendees.some(a => {
|
||||
const email = (a.emailAddress?.address ?? '').toLowerCase();
|
||||
if (email === userEmail.toLowerCase()) return false;
|
||||
const domain = email.split('@')[1];
|
||||
return domain && !internalDomains.has(domain);
|
||||
});
|
||||
|
||||
for (const period of PERIODS) {
|
||||
if (eventTime >= cutoffs[period]) {
|
||||
result[period].meetingCount++;
|
||||
result[period].durationSeconds += durationSec;
|
||||
if (hasExternal) result[period].externalCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export class EngagementSyncService {
|
||||
private syncInProgress = false;
|
||||
|
||||
isSyncInProgress(): boolean {
|
||||
return this.syncInProgress;
|
||||
}
|
||||
|
||||
async sync(): Promise<{ usersUpserted: number; snapshotsUpserted: number }> {
|
||||
if (this.syncInProgress) {
|
||||
throw new Error('Engagement sync already in progress');
|
||||
}
|
||||
|
||||
this.syncInProgress = true;
|
||||
const startTime = Date.now();
|
||||
console.log('[ENGAGEMENT-SYNC] Starting sync...');
|
||||
|
||||
try {
|
||||
const client = getMsgraphClient();
|
||||
|
||||
// 1. Fetch org's verified domains (to identify external attendees)
|
||||
const orgDomains = await client.getOrganizationDomains();
|
||||
const internalDomains = new Set(orgDomains);
|
||||
console.log(`[ENGAGEMENT-SYNC] Internal domains: ${[...internalDomains].join(', ')}`);
|
||||
|
||||
// 2. Sync users
|
||||
console.log('[ENGAGEMENT-SYNC] Fetching Graph users...');
|
||||
const graphUsers = await client.getUsers();
|
||||
const licencedUsers = graphUsers.filter(u => u.mail || u.userPrincipalName);
|
||||
|
||||
for (const user of licencedUsers) {
|
||||
const email = (user.mail || user.userPrincipalName || '').toLowerCase();
|
||||
if (!email) continue;
|
||||
|
||||
await postgresClient.query(
|
||||
`INSERT INTO graph_users (id, display_name, email, job_title, department, account_enabled, synced_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
email = EXCLUDED.email,
|
||||
job_title = EXCLUDED.job_title,
|
||||
department = EXCLUDED.department,
|
||||
account_enabled = EXCLUDED.account_enabled,
|
||||
synced_at = NOW()`,
|
||||
[user.id, user.displayName, email, user.jobTitle, user.department, user.accountEnabled ?? true]
|
||||
);
|
||||
}
|
||||
console.log(`[ENGAGEMENT-SYNC] Upserted ${licencedUsers.length} graph users`);
|
||||
|
||||
// 3. Fetch Teams + Email activity reports for each period
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const now = new Date();
|
||||
|
||||
// Build snapshot data map: email → period → data
|
||||
type SnapshotData = {
|
||||
teamsChatMessages: number;
|
||||
teamsPrivateMessages: number;
|
||||
teamsCalls: number;
|
||||
teamsMeetingsAttended: number;
|
||||
teamsMeetingsOrganized: number;
|
||||
audioDurationSeconds: number;
|
||||
emailsSent: number;
|
||||
emailsReceived: number;
|
||||
emailsRead: number;
|
||||
meetingDurationSeconds: number;
|
||||
meetingsWithExternal: number;
|
||||
lastActivityDate: string | null;
|
||||
afterHoursMessages: number;
|
||||
};
|
||||
|
||||
const snapshotMap = new Map<string, Map<Period, SnapshotData>>();
|
||||
|
||||
for (const period of PERIODS) {
|
||||
console.log(`[ENGAGEMENT-SYNC] Fetching activity for period ${period}...`);
|
||||
|
||||
const [teamsRows, emailRows] = await Promise.all([
|
||||
client.getTeamsActivity(period).catch(err => {
|
||||
console.warn(`[ENGAGEMENT-SYNC] Teams activity failed for ${period}:`, err.message);
|
||||
return [];
|
||||
}),
|
||||
client.getEmailActivity(period).catch(err => {
|
||||
console.warn(`[ENGAGEMENT-SYNC] Email activity failed for ${period}:`, err.message);
|
||||
return [];
|
||||
}),
|
||||
]);
|
||||
|
||||
const teamsMap = new Map(teamsRows.map(r => [r.userPrincipalName.toLowerCase(), r]));
|
||||
const emailMap = new Map(emailRows.map(r => [r.userPrincipalName.toLowerCase(), r]));
|
||||
const allEmails = new Set([
|
||||
...teamsRows.map(r => r.userPrincipalName.toLowerCase()),
|
||||
...emailRows.map(r => r.userPrincipalName.toLowerCase()),
|
||||
]);
|
||||
|
||||
for (const email of allEmails) {
|
||||
const teams = teamsMap.get(email);
|
||||
const mail = emailMap.get(email);
|
||||
|
||||
const dates = [teams?.lastActivityDate, mail?.lastActivityDate].filter(Boolean) as string[];
|
||||
const lastActivity = dates.length > 0 ? dates.sort().reverse()[0] : null;
|
||||
|
||||
if (!snapshotMap.has(email)) snapshotMap.set(email, new Map());
|
||||
snapshotMap.get(email)!.set(period, {
|
||||
teamsChatMessages: teams?.teamChatMessageCount ?? 0,
|
||||
teamsPrivateMessages: teams?.privateChatMessageCount ?? 0,
|
||||
teamsCalls: teams?.callCount ?? 0,
|
||||
teamsMeetingsAttended: teams?.meetingsAttendedCount ?? 0,
|
||||
teamsMeetingsOrganized: teams?.meetingsOrganizedCount ?? 0,
|
||||
audioDurationSeconds: teams?.audioDurationSeconds ?? 0,
|
||||
emailsSent: mail?.sendCount ?? 0,
|
||||
emailsReceived: mail?.receiveCount ?? 0,
|
||||
emailsRead: mail?.readCount ?? 0,
|
||||
meetingDurationSeconds: 0,
|
||||
meetingsWithExternal: 0,
|
||||
lastActivityDate: lastActivity,
|
||||
afterHoursMessages: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fetch calendar events per user (90 days, aggregate into all period buckets)
|
||||
console.log('[ENGAGEMENT-SYNC] Fetching calendar events...');
|
||||
const activeUsers = licencedUsers.filter(u => {
|
||||
const email = (u.mail || u.userPrincipalName || '').toLowerCase();
|
||||
return snapshotMap.has(email);
|
||||
});
|
||||
|
||||
// Build contact email → { contactId, companyId } index for attendee matching
|
||||
const contactRows = await postgresClient.query(
|
||||
`SELECT id, company_id, LOWER(email_address) as e1,
|
||||
LOWER(email_address2) as e2, LOWER(email_address3) as e3
|
||||
FROM contacts WHERE (is_deleted = false OR is_deleted IS NULL)`
|
||||
);
|
||||
const contactEmailIndex = new Map<string, { contactId: number; companyId: number | null }>();
|
||||
for (const row of contactRows.rows) {
|
||||
for (const e of [row.e1, row.e2, row.e3]) {
|
||||
if (e && !contactEmailIndex.has(e)) {
|
||||
contactEmailIndex.set(e, { contactId: row.id, companyId: row.company_id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const calStart = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||
let calFetched = 0;
|
||||
let calSkipped = 0;
|
||||
|
||||
for (const user of activeUsers) {
|
||||
const email = (user.mail || user.userPrincipalName || '').toLowerCase();
|
||||
try {
|
||||
const events = await client.getUserCalendarEvents(user.id, calStart, now);
|
||||
if (events.length > 0) {
|
||||
const buckets = computeCalendarBuckets(events, email, internalDomains, now);
|
||||
for (const period of PERIODS) {
|
||||
const snap = snapshotMap.get(email)?.get(period);
|
||||
if (snap) {
|
||||
snap.meetingDurationSeconds = buckets[period].durationSeconds;
|
||||
snap.meetingsWithExternal = buckets[period].externalCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Persist individual meeting records
|
||||
for (const event of events) {
|
||||
if (!event.id) continue;
|
||||
try {
|
||||
const startTime = new Date(event.start.dateTime);
|
||||
const endTime = new Date(event.end.dateTime);
|
||||
const durationMinutes = Math.max(
|
||||
0,
|
||||
Math.round((endTime.getTime() - startTime.getTime()) / 60000)
|
||||
);
|
||||
const externalAttendees = event.attendees.filter(a => {
|
||||
const aEmail = (a.emailAddress?.address ?? '').toLowerCase();
|
||||
if (aEmail === email) return false;
|
||||
const domain = aEmail.split('@')[1];
|
||||
return domain && !internalDomains.has(domain);
|
||||
});
|
||||
const attendeeCount = event.attendees.length;
|
||||
|
||||
const meetingResult = await postgresClient.query(
|
||||
`INSERT INTO teams_meetings
|
||||
(graph_event_id, user_email, subject, start_time, end_time,
|
||||
duration_minutes, is_online_meeting, attendee_count, synced_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
|
||||
ON CONFLICT (user_email, graph_event_id) DO UPDATE SET
|
||||
subject = EXCLUDED.subject,
|
||||
start_time = EXCLUDED.start_time,
|
||||
end_time = EXCLUDED.end_time,
|
||||
duration_minutes = EXCLUDED.duration_minutes,
|
||||
is_online_meeting = EXCLUDED.is_online_meeting,
|
||||
attendee_count = EXCLUDED.attendee_count,
|
||||
synced_at = NOW()
|
||||
RETURNING id`,
|
||||
[event.id, email, event.subject, startTime, endTime,
|
||||
durationMinutes, event.isOnlineMeeting, attendeeCount]
|
||||
);
|
||||
const meetingId = meetingResult.rows[0]?.id;
|
||||
if (!meetingId) continue;
|
||||
|
||||
// Re-sync attendees clean
|
||||
await postgresClient.query(
|
||||
`DELETE FROM teams_meeting_attendees WHERE meeting_id = $1`,
|
||||
[meetingId]
|
||||
);
|
||||
|
||||
let clientCount = 0;
|
||||
for (const att of externalAttendees) {
|
||||
const attEmail = (att.emailAddress?.address ?? '').toLowerCase();
|
||||
const attName = att.emailAddress?.name ?? null;
|
||||
const match = attEmail ? contactEmailIndex.get(attEmail) : undefined;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO teams_meeting_attendees
|
||||
(meeting_id, attendee_email, attendee_name, matched_contact_id, matched_company_id)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[meetingId, attEmail || null, attName,
|
||||
match?.contactId ?? null, match?.companyId ?? null]
|
||||
);
|
||||
if (match?.companyId) clientCount++;
|
||||
}
|
||||
|
||||
await postgresClient.query(
|
||||
`UPDATE teams_meetings
|
||||
SET client_attendee_count = $1, has_client_attendees = $2
|
||||
WHERE id = $3`,
|
||||
[clientCount, clientCount > 0, meetingId]
|
||||
);
|
||||
} catch (meetingErr) {
|
||||
const msg = meetingErr instanceof Error ? meetingErr.message : String(meetingErr);
|
||||
console.warn(`[ENGAGEMENT-SYNC] Meeting persist failed for event ${event.id}: ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
calFetched++;
|
||||
} catch (err) {
|
||||
calSkipped++;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[ENGAGEMENT-SYNC] Calendar fetch failed for ${email}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[ENGAGEMENT-SYNC] Calendar: ${calFetched} fetched, ${calSkipped} skipped`);
|
||||
|
||||
// 5. Count after-hours messages per user (requires Chat.Read.All permission)
|
||||
// After-hours window: 5:30 PM – 7:00 AM America/New_York
|
||||
const AFTER_HOURS_START_MIN = 17 * 60 + 30; // 1050 — 5:30 PM local
|
||||
const AFTER_HOURS_END_MIN = 7 * 60; // 420 — 7:00 AM local
|
||||
|
||||
// Returns minutes since midnight in America/New_York (handles EST/EDT automatically)
|
||||
function toEasternMinutes(dt: Date): number {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/New_York',
|
||||
hour: 'numeric', minute: 'numeric', hour12: false,
|
||||
}).formatToParts(dt);
|
||||
const h = parseInt(parts.find(p => p.type === 'hour')?.value ?? '0');
|
||||
const m = parseInt(parts.find(p => p.type === 'minute')?.value ?? '0');
|
||||
return h * 60 + m;
|
||||
}
|
||||
const periodCutoffs: Record<Period, number> = {
|
||||
D7: now.getTime() - 7 * 24 * 60 * 60 * 1000,
|
||||
D30: now.getTime() - 30 * 24 * 60 * 60 * 1000,
|
||||
D90: now.getTime() - 90 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
let msgFetched = 0;
|
||||
let msgSkipped = 0;
|
||||
|
||||
console.log(`[ENGAGEMENT-SYNC] Fetching after-hours messages for ${activeUsers.length} users...`);
|
||||
for (const user of activeUsers) {
|
||||
const email = (user.mail || user.userPrincipalName || '').toLowerCase();
|
||||
if (!snapshotMap.has(email)) continue;
|
||||
try {
|
||||
const messages: UserMessage[] = await client.getUserMessages(user.id, calStart, now);
|
||||
const afterD90 = messages.filter(msg => {
|
||||
const estMin = toEasternMinutes(new Date(msg.createdDateTime));
|
||||
return estMin >= AFTER_HOURS_START_MIN || estMin < AFTER_HOURS_END_MIN;
|
||||
}).length;
|
||||
console.log(`[ENGAGEMENT-SYNC] ${email}: ${messages.length} msgs total, ${afterD90} after-hours`);
|
||||
// Bucket by period
|
||||
for (const period of PERIODS) {
|
||||
const snap = snapshotMap.get(email)?.get(period);
|
||||
if (!snap) continue;
|
||||
snap.afterHoursMessages = messages.filter(msg => {
|
||||
const msgMs = new Date(msg.createdDateTime).getTime();
|
||||
if (msgMs < periodCutoffs[period]) return false;
|
||||
const estMin = toEasternMinutes(new Date(msg.createdDateTime));
|
||||
return estMin >= AFTER_HOURS_START_MIN || estMin < AFTER_HOURS_END_MIN;
|
||||
}).length;
|
||||
}
|
||||
msgFetched++;
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[ENGAGEMENT-SYNC] Message fetch failed for ${email}: ${errMsg.slice(0, 200)}`);
|
||||
msgSkipped++;
|
||||
}
|
||||
// Small pause between users to stay under Graph API rate limits (10 req/10 sec per app)
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
console.log(`[ENGAGEMENT-SYNC] After-hours messages: ${msgFetched} fetched, ${msgSkipped} skipped`);
|
||||
|
||||
// 6. Upsert all snapshots
|
||||
let totalSnapshots = 0;
|
||||
for (const [email, periodMap] of snapshotMap) {
|
||||
for (const [period, snap] of periodMap) {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO engagement_snapshots (
|
||||
user_email, period_type, period_end,
|
||||
teams_chat_messages, teams_private_messages, teams_calls,
|
||||
teams_meetings_attended, teams_meetings_organized,
|
||||
emails_sent, emails_received, emails_read,
|
||||
audio_duration_seconds, meeting_duration_seconds, meetings_with_external,
|
||||
after_hours_messages, last_activity_date, synced_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,NOW())
|
||||
ON CONFLICT (user_email, period_type, period_end) DO UPDATE SET
|
||||
teams_chat_messages = EXCLUDED.teams_chat_messages,
|
||||
teams_private_messages = EXCLUDED.teams_private_messages,
|
||||
teams_calls = EXCLUDED.teams_calls,
|
||||
teams_meetings_attended = EXCLUDED.teams_meetings_attended,
|
||||
teams_meetings_organized = EXCLUDED.teams_meetings_organized,
|
||||
emails_sent = EXCLUDED.emails_sent,
|
||||
emails_received = EXCLUDED.emails_received,
|
||||
emails_read = EXCLUDED.emails_read,
|
||||
audio_duration_seconds = EXCLUDED.audio_duration_seconds,
|
||||
meeting_duration_seconds = EXCLUDED.meeting_duration_seconds,
|
||||
meetings_with_external = EXCLUDED.meetings_with_external,
|
||||
after_hours_messages = EXCLUDED.after_hours_messages,
|
||||
last_activity_date = EXCLUDED.last_activity_date,
|
||||
synced_at = NOW()`,
|
||||
[
|
||||
email, period, today,
|
||||
snap.teamsChatMessages, snap.teamsPrivateMessages, snap.teamsCalls,
|
||||
snap.teamsMeetingsAttended, snap.teamsMeetingsOrganized,
|
||||
snap.emailsSent, snap.emailsReceived, snap.emailsRead,
|
||||
snap.audioDurationSeconds, snap.meetingDurationSeconds, snap.meetingsWithExternal,
|
||||
snap.afterHoursMessages, snap.lastActivityDate,
|
||||
]
|
||||
);
|
||||
totalSnapshots++;
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.log(`[ENGAGEMENT-SYNC] Done in ${duration}ms. Users: ${licencedUsers.length}, Snapshots: ${totalSnapshots}`);
|
||||
|
||||
return { usersUpserted: licencedUsers.length, snapshotsUpserted: totalSnapshots };
|
||||
} finally {
|
||||
this.syncInProgress = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: EngagementSyncService | null = null;
|
||||
|
||||
export function getEngagementSyncService(): EngagementSyncService {
|
||||
if (!_instance) {
|
||||
_instance = new EngagementSyncService();
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
buildActiveFilter,
|
||||
buildDateRangeFilter,
|
||||
buildContractsFilter,
|
||||
buildContractServicesFilter,
|
||||
buildProjectsFilter,
|
||||
buildTimeEntriesFilter,
|
||||
buildBillingItemsFilter,
|
||||
|
|
@ -125,6 +126,9 @@ export class EntitySyncService {
|
|||
if (entity === EntityType.CONTRACTS) {
|
||||
filters.push(...buildContractsFilter());
|
||||
entityLogger.info('Full sync with status filter for active contracts');
|
||||
} else if (entity === EntityType.CONTRACT_SERVICES) {
|
||||
filters.push(...buildContractServicesFilter());
|
||||
entityLogger.info('Full sync of all contract services');
|
||||
} else if (entity === EntityType.PROJECTS) {
|
||||
filters.push(...buildProjectsFilter());
|
||||
entityLogger.info('Full sync with status filter for non-completed projects');
|
||||
|
|
@ -365,6 +369,21 @@ export class EntitySyncService {
|
|||
|
||||
entityLogger.info('Upserted records to PostgreSQL', { upsertedCount });
|
||||
|
||||
// Post-sync enrichment: populate service_name from autotask_services lookup
|
||||
if (entity === EntityType.CONTRACT_SERVICES) {
|
||||
try {
|
||||
const enrichResult = await postgresClient.query(`
|
||||
UPDATE contract_services cs
|
||||
SET service_name = s.name
|
||||
FROM autotask_services s
|
||||
WHERE cs.service_id = s.id AND cs.service_name IS NULL AND s.name IS NOT NULL
|
||||
`);
|
||||
entityLogger.info('Enriched contract_services with service names', { rowCount: enrichResult.rowCount });
|
||||
} catch (err) {
|
||||
entityLogger.warn('service_name enrichment skipped (autotask_services may not be synced yet)');
|
||||
}
|
||||
}
|
||||
|
||||
// For full sync, soft delete records not in the fetched set
|
||||
// IMPORTANT: Skip soft deletes if ANY filters were applied (date, status, active, etc.)
|
||||
// because we cannot know what records exist outside the filter criteria
|
||||
|
|
|
|||
543
lib/services/morning-summary-service.ts
Normal file
543
lib/services/morning-summary-service.ts
Normal file
|
|
@ -0,0 +1,543 @@
|
|||
import { postgresClient } from './postgres-client';
|
||||
import { ZabbixClient } from './zabbix-client';
|
||||
|
||||
export interface MorningSummaryProblem {
|
||||
eventid: string;
|
||||
hostid: string;
|
||||
hostName: string;
|
||||
clientName: string;
|
||||
triggerName: string;
|
||||
severity: number;
|
||||
severityLabel: string;
|
||||
startedAt: Date;
|
||||
durationLabel: string;
|
||||
acknowledged: boolean;
|
||||
needsAttention: boolean;
|
||||
}
|
||||
|
||||
export interface MorningSummaryResolved {
|
||||
eventid: string;
|
||||
hostName: string;
|
||||
clientName: string;
|
||||
triggerName: string;
|
||||
startedAt: Date;
|
||||
resolvedAt: Date;
|
||||
durationLabel: string;
|
||||
}
|
||||
|
||||
export interface MorningSummary {
|
||||
generatedAt: Date;
|
||||
windowFrom: Date;
|
||||
windowTo: Date;
|
||||
isWeekendWindow: boolean;
|
||||
openProblems: MorningSummaryProblem[];
|
||||
resolvedOvernight: MorningSummaryResolved[];
|
||||
openCount: number;
|
||||
resolvedCount: number;
|
||||
mttrMinutes: number | null;
|
||||
clientsAffected: string[];
|
||||
}
|
||||
|
||||
export interface WebhookConfig {
|
||||
id: number;
|
||||
label: string;
|
||||
webhook_url: string;
|
||||
enabled: boolean;
|
||||
last_delivered_at: string | null;
|
||||
last_status: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SummaryConfig {
|
||||
weekend_suppression: boolean;
|
||||
monday_extended_window: boolean;
|
||||
severity_filter: number;
|
||||
outages_only: boolean;
|
||||
}
|
||||
|
||||
export interface DeliveryResult {
|
||||
webhookId: number;
|
||||
label: string;
|
||||
success: boolean;
|
||||
httpStatus?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const SEVERITY_LABELS: Record<number, string> = {
|
||||
0: 'Not classified',
|
||||
1: 'Information',
|
||||
2: 'Warning',
|
||||
3: 'Average',
|
||||
4: 'High',
|
||||
5: 'Disaster',
|
||||
};
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const totalMinutes = Math.floor(ms / 60000);
|
||||
if (totalMinutes < 1) return '<1m';
|
||||
if (totalMinutes < 60) return `${totalMinutes}m`;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const mins = totalMinutes % 60;
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
function makeZabbix(): ZabbixClient {
|
||||
if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) {
|
||||
throw new Error('Zabbix not configured: ZABBIX_API_URL and ZABBIX_API_TOKEN required');
|
||||
}
|
||||
return new ZabbixClient({
|
||||
apiUrl: process.env.ZABBIX_API_URL,
|
||||
apiToken: process.env.ZABBIX_API_TOKEN,
|
||||
});
|
||||
}
|
||||
|
||||
function getWindowStart(now: Date, config: SummaryConfig): { from: Date; isWeekend: boolean } {
|
||||
const day = now.getDay(); // 0=Sun,1=Mon
|
||||
if (day === 1 && config.monday_extended_window) {
|
||||
const friday = new Date(now);
|
||||
friday.setDate(friday.getDate() - 3);
|
||||
friday.setHours(18, 0, 0, 0);
|
||||
return { from: friday, isWeekend: true };
|
||||
}
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
yesterday.setHours(18, 0, 0, 0);
|
||||
return { from: yesterday, isWeekend: false };
|
||||
}
|
||||
|
||||
function buildAdaptiveCard(summary: MorningSummary): object {
|
||||
const headerText = `☀️ Morning NOC Summary${summary.isWeekendWindow ? ' — Weekend Coverage' : ''}`;
|
||||
|
||||
const mttrText = summary.mttrMinutes != null ? formatDuration(summary.mttrMinutes * 60000) : '—';
|
||||
|
||||
const statsColumns = {
|
||||
type: 'ColumnSet',
|
||||
columns: [
|
||||
{
|
||||
type: 'Column', width: 'stretch',
|
||||
items: [{
|
||||
type: 'TextBlock',
|
||||
text: `${summary.openCount} Open`,
|
||||
weight: 'Bolder', color: summary.openCount > 0 ? 'Attention' : 'Default',
|
||||
wrap: true,
|
||||
}],
|
||||
},
|
||||
{
|
||||
type: 'Column', width: 'stretch',
|
||||
items: [{
|
||||
type: 'TextBlock',
|
||||
text: `${summary.resolvedCount} Resolved`,
|
||||
weight: 'Bolder', color: summary.resolvedCount > 0 ? 'Good' : 'Default',
|
||||
wrap: true,
|
||||
}],
|
||||
},
|
||||
{
|
||||
type: 'Column', width: 'stretch',
|
||||
items: [{
|
||||
type: 'TextBlock',
|
||||
text: `${mttrText} MTTR`,
|
||||
weight: 'Bolder', wrap: true,
|
||||
}],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const bodyItems: object[] = [
|
||||
{ type: 'TextBlock', text: headerText, weight: 'Bolder', size: 'Large', wrap: true },
|
||||
statsColumns,
|
||||
{ type: 'TextBlock', text: ' ', spacing: 'None' },
|
||||
];
|
||||
|
||||
// Open issues section
|
||||
if (summary.openProblems.length > 0) {
|
||||
const facts = summary.openProblems.map(p => ({
|
||||
title: `${p.clientName} / ${p.hostName}`,
|
||||
value: `${p.triggerName} — ${p.durationLabel}`,
|
||||
}));
|
||||
|
||||
bodyItems.push({
|
||||
type: 'Container',
|
||||
style: 'attention',
|
||||
items: [
|
||||
{ type: 'TextBlock', text: 'Open Issues', weight: 'Bolder', color: 'Attention', wrap: true },
|
||||
{ type: 'FactSet', facts },
|
||||
],
|
||||
});
|
||||
} else {
|
||||
bodyItems.push({
|
||||
type: 'Container',
|
||||
style: 'good',
|
||||
items: [{ type: 'TextBlock', text: 'All Clear — No open issues', weight: 'Bolder', color: 'Good', wrap: true }],
|
||||
});
|
||||
}
|
||||
|
||||
// Resolved overnight section
|
||||
if (summary.resolvedOvernight.length > 0) {
|
||||
const shown = summary.resolvedOvernight.slice(0, 5);
|
||||
const extra = summary.resolvedOvernight.length - 5;
|
||||
const facts = shown.map(r => ({
|
||||
title: `${r.clientName} / ${r.hostName}`,
|
||||
value: `${r.triggerName} — ${r.durationLabel}`,
|
||||
}));
|
||||
if (extra > 0) {
|
||||
facts.push({ title: '', value: `+${extra} more — all resolved` });
|
||||
}
|
||||
|
||||
bodyItems.push({
|
||||
type: 'Container',
|
||||
style: 'good',
|
||||
items: [
|
||||
{ type: 'TextBlock', text: 'Resolved', weight: 'Bolder', color: 'Good', wrap: true },
|
||||
{ type: 'FactSet', facts },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Window info footer
|
||||
const windowFrom = summary.windowFrom.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
const windowTo = summary.windowTo.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
bodyItems.push({
|
||||
type: 'TextBlock',
|
||||
text: `Window: ${windowFrom} → ${windowTo}`,
|
||||
size: 'Small', color: 'Default', isSubtle: true, 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' },
|
||||
{ type: 'Action.OpenUrl', title: 'View Problems', url: 'https://zabbix.wulfconsulting.cloud/zabbix.php?action=problem.view' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export class MorningSummaryService {
|
||||
async getConfig(): Promise<SummaryConfig> {
|
||||
const result = await postgresClient.query('SELECT * FROM morning_summary_config WHERE id = 1');
|
||||
if (result.rows.length === 0) {
|
||||
return { weekend_suppression: true, monday_extended_window: true, severity_filter: 2, outages_only: false };
|
||||
}
|
||||
return result.rows[0] as SummaryConfig;
|
||||
}
|
||||
|
||||
async updateConfig(updates: Partial<SummaryConfig>): Promise<SummaryConfig> {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (updates.weekend_suppression !== undefined) { fields.push(`weekend_suppression = $${idx++}`); values.push(updates.weekend_suppression); }
|
||||
if (updates.monday_extended_window !== undefined) { fields.push(`monday_extended_window = $${idx++}`); values.push(updates.monday_extended_window); }
|
||||
if (updates.severity_filter !== undefined) { fields.push(`severity_filter = $${idx++}`); values.push(updates.severity_filter); }
|
||||
if (updates.outages_only !== undefined) { fields.push(`outages_only = $${idx++}`); values.push(updates.outages_only); }
|
||||
|
||||
if (fields.length === 0) return this.getConfig();
|
||||
fields.push('updated_at = NOW()');
|
||||
values.push(1);
|
||||
|
||||
const result = await postgresClient.query(
|
||||
`UPDATE morning_summary_config SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
values
|
||||
);
|
||||
return result.rows[0] as SummaryConfig;
|
||||
}
|
||||
|
||||
async getWebhooks(): Promise<WebhookConfig[]> {
|
||||
const result = await postgresClient.query('SELECT * FROM morning_summary_webhooks ORDER BY id');
|
||||
return result.rows as WebhookConfig[];
|
||||
}
|
||||
|
||||
async createWebhook(label: string, webhookUrl: string): Promise<WebhookConfig> {
|
||||
const result = await postgresClient.query(
|
||||
'INSERT INTO morning_summary_webhooks (label, webhook_url, enabled) VALUES ($1, $2, true) RETURNING *',
|
||||
[label, webhookUrl]
|
||||
);
|
||||
return result.rows[0] as WebhookConfig;
|
||||
}
|
||||
|
||||
async updateWebhook(id: number, updates: { label?: string; webhook_url?: string; enabled?: boolean }): Promise<WebhookConfig> {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (updates.label !== undefined) { fields.push(`label = $${idx++}`); values.push(updates.label); }
|
||||
if (updates.webhook_url !== undefined) { fields.push(`webhook_url = $${idx++}`); values.push(updates.webhook_url); }
|
||||
if (updates.enabled !== undefined) { fields.push(`enabled = $${idx++}`); values.push(updates.enabled); }
|
||||
|
||||
if (fields.length === 0) throw new Error('No fields to update');
|
||||
values.push(id);
|
||||
|
||||
const result = await postgresClient.query(
|
||||
`UPDATE morning_summary_webhooks SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
values
|
||||
);
|
||||
return result.rows[0] as WebhookConfig;
|
||||
}
|
||||
|
||||
async deleteWebhook(id: number): Promise<void> {
|
||||
await postgresClient.query('DELETE FROM morning_summary_webhooks WHERE id = $1', [id]);
|
||||
}
|
||||
|
||||
async getLatestSummaryRow(): Promise<{
|
||||
id: number;
|
||||
generated_at: string;
|
||||
window_from: string;
|
||||
window_to: string;
|
||||
open_count: number;
|
||||
resolved_count: number;
|
||||
mttr_minutes: number | null;
|
||||
clients_affected: string[];
|
||||
is_weekend_window: boolean;
|
||||
card_payload: object | null;
|
||||
delivery_status: object;
|
||||
} | null> {
|
||||
const result = await postgresClient.query('SELECT * FROM morning_summaries ORDER BY generated_at DESC LIMIT 1');
|
||||
return result.rows.length > 0 ? result.rows[0] : null;
|
||||
}
|
||||
|
||||
async getSummaryHistory(limit = 10): Promise<Array<{
|
||||
id: number;
|
||||
generated_at: string;
|
||||
open_count: number;
|
||||
resolved_count: number;
|
||||
mttr_minutes: number | null;
|
||||
is_weekend_window: boolean;
|
||||
delivery_status: object;
|
||||
}>> {
|
||||
const result = await postgresClient.query(
|
||||
'SELECT id, generated_at, open_count, resolved_count, mttr_minutes, is_weekend_window, delivery_status FROM morning_summaries ORDER BY generated_at DESC LIMIT $1',
|
||||
[limit]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull data from Zabbix and build the MorningSummary + Adaptive Card.
|
||||
*/
|
||||
async aggregate(overrideWindowFrom?: Date): Promise<{ summary: MorningSummary; card: object }> {
|
||||
const now = new Date();
|
||||
const config = await this.getConfig();
|
||||
const { from: windowFrom, isWeekend } = overrideWindowFrom
|
||||
? { from: overrideWindowFrom, isWeekend: false }
|
||||
: getWindowStart(now, config);
|
||||
|
||||
const zabbix = makeZabbix();
|
||||
|
||||
// Fetch open problems and resolved events in parallel
|
||||
const [rawProblems, rawResolved] = await Promise.all([
|
||||
zabbix.getOpenProblems(config.severity_filter),
|
||||
zabbix.getResolvedEvents(windowFrom, now),
|
||||
]);
|
||||
|
||||
// Build the set of unique triggerids from both result sets
|
||||
const allTriggerIds = [
|
||||
...new Set([
|
||||
...rawProblems.map(p => p.objectid),
|
||||
...rawResolved.map(e => e.objectid),
|
||||
]),
|
||||
];
|
||||
|
||||
// Resolve triggerid → { hostid, hostName } for ENABLED hosts only.
|
||||
// Triggerids tied solely to disabled hosts will be absent from this map.
|
||||
const triggerHostMap = await zabbix.getTriggerEnabledHosts(allTriggerIds);
|
||||
|
||||
// Fetch groups for every enabled hostid so we can derive clientName
|
||||
const enabledHostIds = [...new Set([...triggerHostMap.values()].map(h => h.hostid))];
|
||||
const groupMap = await zabbix.getHostGroupMap(enabledHostIds);
|
||||
|
||||
function resolveClient(triggerid: string): { hostid: string; hostName: string; clientName: string } {
|
||||
const host = triggerHostMap.get(triggerid);
|
||||
if (!host) return { hostid: '', hostName: 'Unknown', clientName: 'Unknown' };
|
||||
const groups = groupMap.get(host.hostid) ?? [];
|
||||
const clientGroup = groups.find((g: string) => g.startsWith('Clients/'));
|
||||
const clientName = clientGroup ? clientGroup.replace('Clients/', '').trim() : host.hostName;
|
||||
return { hostid: host.hostid, hostName: host.hostName, clientName };
|
||||
}
|
||||
|
||||
// Only include problems whose trigger maps to an enabled host; optionally filter to outages only
|
||||
const openProblems: MorningSummaryProblem[] = rawProblems
|
||||
.filter(p => triggerHostMap.has(p.objectid))
|
||||
.filter(p => !config.outages_only || p.name.toLowerCase().includes('unavailable'))
|
||||
.map(p => {
|
||||
const { hostid, hostName, clientName } = resolveClient(p.objectid);
|
||||
const severity = parseInt(p.severity, 10);
|
||||
const startedAt = new Date(parseInt(p.clock, 10) * 1000);
|
||||
const durationMs = now.getTime() - startedAt.getTime();
|
||||
const ackArray = Array.isArray(p.acknowledges) ? p.acknowledges : [];
|
||||
const acknowledged = ackArray.length > 0;
|
||||
const needsAttention = !acknowledged && durationMs > 4 * 60 * 60 * 1000;
|
||||
|
||||
return {
|
||||
eventid: p.eventid,
|
||||
hostid,
|
||||
hostName,
|
||||
clientName,
|
||||
triggerName: p.name,
|
||||
severity,
|
||||
severityLabel: SEVERITY_LABELS[severity] ?? 'Unknown',
|
||||
startedAt,
|
||||
durationLabel: formatDuration(durationMs),
|
||||
acknowledged,
|
||||
needsAttention,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort: needs attention first, then by severity desc, then by duration desc
|
||||
openProblems.sort((a, b) => {
|
||||
if (a.needsAttention !== b.needsAttention) return a.needsAttention ? -1 : 1;
|
||||
if (b.severity !== a.severity) return b.severity - a.severity;
|
||||
return b.startedAt.getTime() - a.startedAt.getTime();
|
||||
});
|
||||
|
||||
// Only include resolved events whose trigger maps to an enabled host; optionally filter to outages only
|
||||
const resolvedOvernight: MorningSummaryResolved[] = rawResolved
|
||||
.filter(ev => triggerHostMap.has(ev.objectid))
|
||||
.filter(ev => !config.outages_only || ev.name.toLowerCase().includes('unavailable'))
|
||||
.map(ev => {
|
||||
const { hostName, clientName } = resolveClient(ev.objectid);
|
||||
const startedAt = new Date(parseInt(ev.clock, 10) * 1000);
|
||||
const resolvedAt = ev.r_clock ? new Date(parseInt(ev.r_clock, 10) * 1000) : now;
|
||||
const durationMs = resolvedAt.getTime() - startedAt.getTime();
|
||||
|
||||
return {
|
||||
eventid: ev.eventid,
|
||||
hostName,
|
||||
clientName,
|
||||
triggerName: ev.name,
|
||||
startedAt,
|
||||
resolvedAt,
|
||||
durationLabel: formatDuration(durationMs),
|
||||
};
|
||||
});
|
||||
|
||||
// Stats
|
||||
const mttrMinutes = resolvedOvernight.length > 0
|
||||
? Math.round(resolvedOvernight.reduce((sum, r) => {
|
||||
return sum + (r.resolvedAt.getTime() - r.startedAt.getTime()) / 60000;
|
||||
}, 0) / resolvedOvernight.length)
|
||||
: null;
|
||||
|
||||
const clientsAffected = [...new Set(openProblems.map(p => p.clientName))].sort();
|
||||
|
||||
const summary: MorningSummary = {
|
||||
generatedAt: now,
|
||||
windowFrom,
|
||||
windowTo: now,
|
||||
isWeekendWindow: isWeekend,
|
||||
openProblems,
|
||||
resolvedOvernight,
|
||||
openCount: openProblems.length,
|
||||
resolvedCount: resolvedOvernight.length,
|
||||
mttrMinutes,
|
||||
clientsAffected,
|
||||
};
|
||||
|
||||
const card = buildAdaptiveCard(summary);
|
||||
|
||||
// Persist
|
||||
await postgresClient.query(
|
||||
`INSERT INTO morning_summaries
|
||||
(generated_at, window_from, window_to, open_count, resolved_count, mttr_minutes,
|
||||
clients_affected, is_weekend_window, card_payload, delivery_status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[
|
||||
now, windowFrom, now,
|
||||
summary.openCount, summary.resolvedCount, summary.mttrMinutes,
|
||||
summary.clientsAffected, summary.isWeekendWindow,
|
||||
JSON.stringify(card), JSON.stringify({}),
|
||||
]
|
||||
);
|
||||
|
||||
return { summary, card };
|
||||
}
|
||||
|
||||
/**
|
||||
* Post an Adaptive Card to one or more webhook URLs.
|
||||
* Returns per-webhook results and updates DB delivery_status.
|
||||
*/
|
||||
async deliver(card: object, webhookIds?: number[]): Promise<DeliveryResult[]> {
|
||||
const all = await this.getWebhooks();
|
||||
const targets = webhookIds
|
||||
? all.filter(w => webhookIds.includes(w.id))
|
||||
: all.filter(w => w.enabled);
|
||||
|
||||
const envelope = {
|
||||
type: 'message',
|
||||
attachments: [{
|
||||
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||
contentUrl: null,
|
||||
content: card,
|
||||
}],
|
||||
};
|
||||
|
||||
const results: DeliveryResult[] = await Promise.all(
|
||||
targets.map(async (webhook): Promise<DeliveryResult> => {
|
||||
try {
|
||||
const res = await fetch(webhook.webhook_url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(envelope),
|
||||
});
|
||||
|
||||
const success = res.ok;
|
||||
const httpStatus = res.status;
|
||||
|
||||
await postgresClient.query(
|
||||
`UPDATE morning_summary_webhooks
|
||||
SET last_delivered_at = NOW(), last_status = $1 WHERE id = $2`,
|
||||
[success ? 'success' : 'failed', webhook.id]
|
||||
);
|
||||
|
||||
return { webhookId: webhook.id, label: webhook.label, success, httpStatus };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
await postgresClient.query(
|
||||
`UPDATE morning_summary_webhooks SET last_delivered_at = NOW(), last_status = 'failed' WHERE id = $1`,
|
||||
[webhook.id]
|
||||
);
|
||||
return { webhookId: webhook.id, label: webhook.label, success: false, error };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Update delivery_status on the latest summary row
|
||||
const statusMap: Record<number, object> = {};
|
||||
for (const r of results) {
|
||||
statusMap[r.webhookId] = { success: r.success, httpStatus: r.httpStatus, error: r.error };
|
||||
}
|
||||
await postgresClient.query(
|
||||
`UPDATE morning_summaries SET delivery_status = $1
|
||||
WHERE id = (SELECT id FROM morning_summaries ORDER BY generated_at DESC LIMIT 1)`,
|
||||
[JSON.stringify(statusMap)]
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full run: aggregate + deliver. Used by scheduler and /send endpoint.
|
||||
*/
|
||||
async run(webhookIds?: number[]): Promise<{ summary: MorningSummary; results: DeliveryResult[] }> {
|
||||
const { summary, card } = await this.aggregate();
|
||||
const results = await this.deliver(card, webhookIds);
|
||||
console.log(`[MORNING-SUMMARY] Open: ${summary.openCount}, Resolved: ${summary.resolvedCount}, Webhooks: ${results.length}`);
|
||||
return { summary, results };
|
||||
}
|
||||
|
||||
/**
|
||||
* Test send: aggregate current data and send to a single webhook only.
|
||||
*/
|
||||
async testSend(webhookId: number): Promise<DeliveryResult> {
|
||||
const { card } = await this.aggregate();
|
||||
const results = await this.deliver(card, [webhookId]);
|
||||
return results[0] ?? { webhookId, label: '', success: false, error: 'Webhook not found' };
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: MorningSummaryService | null = null;
|
||||
export function getMorningSummaryService(): MorningSummaryService {
|
||||
if (!_instance) _instance = new MorningSummaryService();
|
||||
return _instance;
|
||||
}
|
||||
372
lib/services/msgraph-client.ts
Normal file
372
lib/services/msgraph-client.ts
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
/**
|
||||
* Microsoft Graph API Client
|
||||
* Client credentials flow for application permissions
|
||||
* Reports.Read.All, User.Read.All, Calendars.Read
|
||||
*/
|
||||
|
||||
export interface GraphUser {
|
||||
id: string;
|
||||
displayName: string | null;
|
||||
mail: string | null;
|
||||
userPrincipalName: string | null;
|
||||
jobTitle: string | null;
|
||||
department: string | null;
|
||||
accountEnabled: boolean | null;
|
||||
}
|
||||
|
||||
export interface TeamsActivityRow {
|
||||
userPrincipalName: string;
|
||||
lastActivityDate: string;
|
||||
teamChatMessageCount: number;
|
||||
privateChatMessageCount: number;
|
||||
callCount: number;
|
||||
meetingCount: number;
|
||||
meetingsOrganizedCount: number;
|
||||
meetingsAttendedCount: number;
|
||||
audioDurationSeconds: number;
|
||||
}
|
||||
|
||||
export interface EmailActivityRow {
|
||||
userPrincipalName: string;
|
||||
lastActivityDate: string;
|
||||
sendCount: number;
|
||||
receiveCount: number;
|
||||
readCount: number;
|
||||
}
|
||||
|
||||
export interface UserMessage {
|
||||
createdDateTime: string;
|
||||
fromUserId: string | null;
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
subject: string;
|
||||
start: { dateTime: string; timeZone: string };
|
||||
end: { dateTime: string; timeZone: string };
|
||||
attendees: Array<{
|
||||
emailAddress: { address: string; name: string };
|
||||
type: string;
|
||||
}>;
|
||||
isOnlineMeeting: boolean;
|
||||
isCancelled: boolean;
|
||||
}
|
||||
|
||||
export interface MsGraphClientConfig {
|
||||
tenantId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export class MsGraphClient {
|
||||
private config: MsGraphClientConfig;
|
||||
private accessToken: string | null = null;
|
||||
private tokenExpiry: number = 0;
|
||||
|
||||
constructor(config: MsGraphClientConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
private async getToken(): Promise<string> {
|
||||
if (this.accessToken && Date.now() < this.tokenExpiry - 60000) {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
const url = `https://login.microsoftonline.com/${this.config.tenantId}/oauth2/v2.0/token`;
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
scope: 'https://graph.microsoft.com/.default',
|
||||
});
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Graph token request failed: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
this.accessToken = data.access_token;
|
||||
this.tokenExpiry = Date.now() + data.expires_in * 1000;
|
||||
return this.accessToken!;
|
||||
}
|
||||
|
||||
private async fetchJson<T>(path: string, retryCount = 0): Promise<T> {
|
||||
const token = await this.getToken();
|
||||
const res = await fetch(`https://graph.microsoft.com/v1.0${path}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Retry on 429 rate limit — respect Retry-After header (minimum 30s)
|
||||
if (res.status === 429 && retryCount < 4) {
|
||||
const retryAfter = Math.max(30, parseInt(res.headers.get('Retry-After') || '30', 10));
|
||||
await new Promise(r => setTimeout(r, retryAfter * 1000));
|
||||
return this.fetchJson(path, retryCount + 1);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Graph API error ${res.status} for ${path}: ${text}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
private async fetchCsv(path: string): Promise<string> {
|
||||
const token = await this.getToken();
|
||||
const res = await fetch(`https://graph.microsoft.com/v1.0${path}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'text/csv',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Graph API error ${res.status} for ${path}: ${text}`);
|
||||
}
|
||||
|
||||
return res.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CSV — handles BOM prefix on first header column
|
||||
*/
|
||||
private parseCsv(csv: string): Record<string, string>[] {
|
||||
const lines = csv.split('\n').filter(l => l.trim());
|
||||
if (lines.length < 2) return [];
|
||||
|
||||
// Strip BOM from first header if present
|
||||
const rawHeaders = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, '').replace(/^\uFEFF/, ''));
|
||||
const rows: Record<string, string>[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const values = lines[i].split(',').map(v => v.trim().replace(/^"|"$/g, ''));
|
||||
const row: Record<string, string> = {};
|
||||
rawHeaders.forEach((h, idx) => {
|
||||
row[h] = values[idx] ?? '';
|
||||
});
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private parseInt0(v: string): number {
|
||||
const n = parseInt(v);
|
||||
return isNaN(n) ? 0 : n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users in the tenant (paginated)
|
||||
*/
|
||||
async getUsers(): Promise<GraphUser[]> {
|
||||
const users: GraphUser[] = [];
|
||||
let url: string | null = '/users?$select=id,displayName,mail,userPrincipalName,jobTitle,department,accountEnabled&$top=999';
|
||||
|
||||
while (url) {
|
||||
const data: { value: GraphUser[]; '@odata.nextLink'?: string } = await this.fetchJson(url);
|
||||
users.push(...data.value);
|
||||
if (data['@odata.nextLink']) {
|
||||
url = data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '');
|
||||
} else {
|
||||
url = null;
|
||||
}
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tenant's verified email domains (used to identify external attendees)
|
||||
*/
|
||||
async getOrganizationDomains(): Promise<string[]> {
|
||||
try {
|
||||
const data = await this.fetchJson<{
|
||||
value: Array<{ verifiedDomains: Array<{ name: string; isDefault: boolean }> }>;
|
||||
}>('/organization?$select=verifiedDomains');
|
||||
return (data.value[0]?.verifiedDomains ?? []).map(d => d.name.toLowerCase());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Teams user activity report — includes audio duration in seconds
|
||||
* period: 'D7' | 'D30' | 'D90'
|
||||
*/
|
||||
async getTeamsActivity(period: string): Promise<TeamsActivityRow[]> {
|
||||
const csv = await this.fetchCsv(`/reports/getTeamsUserActivityUserDetail(period='${period}')`);
|
||||
const rows = this.parseCsv(csv);
|
||||
|
||||
return rows
|
||||
.filter(r => r['User Principal Name'])
|
||||
.map(r => ({
|
||||
userPrincipalName: r['User Principal Name'] || '',
|
||||
lastActivityDate: r['Last Activity Date'] || '',
|
||||
teamChatMessageCount: this.parseInt0(r['Team Chat Message Count']),
|
||||
privateChatMessageCount: this.parseInt0(r['Private Chat Message Count']),
|
||||
callCount: this.parseInt0(r['Call Count']),
|
||||
meetingCount: this.parseInt0(r['Meeting Count']),
|
||||
meetingsOrganizedCount: this.parseInt0(r['Meetings Organized Count']),
|
||||
meetingsAttendedCount: this.parseInt0(r['Meetings Attended Count']),
|
||||
// "Audio Duration In Seconds" is the pre-computed seconds column
|
||||
audioDurationSeconds: this.parseInt0(r['Audio Duration In Seconds']),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Email activity report
|
||||
* period: 'D7' | 'D30' | 'D90'
|
||||
*/
|
||||
async getEmailActivity(period: string): Promise<EmailActivityRow[]> {
|
||||
const csv = await this.fetchCsv(`/reports/getEmailActivityUserDetail(period='${period}')`);
|
||||
const rows = this.parseCsv(csv);
|
||||
|
||||
return rows
|
||||
.filter(r => r['User Principal Name'])
|
||||
.map(r => ({
|
||||
userPrincipalName: r['User Principal Name'] || '',
|
||||
lastActivityDate: r['Last Activity Date'] || '',
|
||||
sendCount: this.parseInt0(r['Send Count']),
|
||||
receiveCount: this.parseInt0(r['Receive Count']),
|
||||
readCount: this.parseInt0(r['Read Count']),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages sent by a user in a date range, across all their chats.
|
||||
* Requires Chat.Read.All application permission.
|
||||
*
|
||||
* Strategy (based on Graph API docs):
|
||||
* 1. GET /users/{id}/chats with $expand=lastMessagePreview to find recently active chats.
|
||||
* Stop paging when the chat's last message preview is older than startDate.
|
||||
* 2. For each recent chat, GET /chats/{id}/messages with:
|
||||
* $filter=lastModifiedDateTime gt {start} and lastModifiedDateTime lt {end}
|
||||
* $orderby=lastModifiedDateTime desc (newest first)
|
||||
*
|
||||
* Returns only messages sent by this user (from.user.id match).
|
||||
*/
|
||||
async getUserMessages(
|
||||
userId: string,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<UserMessage[]> {
|
||||
const messages: UserMessage[] = [];
|
||||
const startIso = startDate.toISOString().slice(0, 19) + 'Z';
|
||||
const endIso = endDate.toISOString().slice(0, 19) + 'Z';
|
||||
const startMs = startDate.getTime();
|
||||
|
||||
// Step 1: get chats sorted by most recent activity, stop when too old
|
||||
const chatIds: string[] = [];
|
||||
let chatUrl: string | null =
|
||||
`/users/${encodeURIComponent(userId)}/chats` +
|
||||
`?$expand=lastMessagePreview&$orderby=lastMessagePreview/createdDateTime desc&$top=50`;
|
||||
|
||||
while (chatUrl && chatIds.length < 200) {
|
||||
const data: {
|
||||
value: Array<{ id: string; lastMessagePreview?: { createdDateTime?: string } }>;
|
||||
'@odata.nextLink'?: string;
|
||||
} = await this.fetchJson(chatUrl);
|
||||
|
||||
let hitOldChat = false;
|
||||
for (const chat of data.value) {
|
||||
const lastMsgTime = chat.lastMessagePreview?.createdDateTime
|
||||
? new Date(chat.lastMessagePreview.createdDateTime).getTime()
|
||||
: null;
|
||||
if (lastMsgTime !== null && lastMsgTime < startMs) {
|
||||
hitOldChat = true;
|
||||
break;
|
||||
}
|
||||
chatIds.push(chat.id);
|
||||
}
|
||||
|
||||
chatUrl = hitOldChat
|
||||
? null
|
||||
: data['@odata.nextLink']
|
||||
? data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '')
|
||||
: null;
|
||||
}
|
||||
|
||||
// Step 2: for each recent chat, get messages in the date range
|
||||
for (const chatId of chatIds) {
|
||||
let msgUrl: string | null =
|
||||
`/chats/${encodeURIComponent(chatId)}/messages` +
|
||||
`?$filter=lastModifiedDateTime gt ${startIso} and lastModifiedDateTime lt ${endIso}` +
|
||||
`&$orderby=lastModifiedDateTime desc&$top=50`;
|
||||
|
||||
let pageCount = 0;
|
||||
try {
|
||||
while (msgUrl && pageCount < 20) {
|
||||
const data: {
|
||||
value: Array<{ createdDateTime: string; from?: { user?: { id: string } } }>;
|
||||
'@odata.nextLink'?: string;
|
||||
} = await this.fetchJson(msgUrl);
|
||||
|
||||
pageCount++;
|
||||
for (const msg of data.value) {
|
||||
if (msg.from?.user?.id !== userId) continue;
|
||||
messages.push({ createdDateTime: msg.createdDateTime, fromUserId: userId });
|
||||
}
|
||||
|
||||
msgUrl = data['@odata.nextLink']
|
||||
? data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '')
|
||||
: null;
|
||||
}
|
||||
} catch {
|
||||
// Skip inaccessible chats (403 on meeting threads, 429 exhausted, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get calendar events for a user in a date range (paginated).
|
||||
* Returns empty array and logs if the mailbox is not Exchange Online (graceful degradation).
|
||||
*/
|
||||
async getUserCalendarEvents(
|
||||
userId: string,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<CalendarEvent[]> {
|
||||
const start = startDate.toISOString();
|
||||
const end = endDate.toISOString();
|
||||
const events: CalendarEvent[] = [];
|
||||
|
||||
let url: string | null =
|
||||
`/users/${encodeURIComponent(userId)}/calendarView` +
|
||||
`?startDateTime=${start}&endDateTime=${end}` +
|
||||
`&$select=id,subject,start,end,attendees,isOnlineMeeting,isCancelled` +
|
||||
`&$top=100`;
|
||||
|
||||
while (url) {
|
||||
try {
|
||||
const data: { value: CalendarEvent[]; '@odata.nextLink'?: string } =
|
||||
await this.fetchJson(url);
|
||||
events.push(...data.value.filter(e => !e.isCancelled));
|
||||
url = data['@odata.nextLink']
|
||||
? data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '')
|
||||
: null;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// Guests and on-prem mailboxes don't support REST API — skip silently
|
||||
if (msg.includes('MailboxNotEnabledForRESTAPI') || msg.includes('404')) {
|
||||
break;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
}
|
||||
42
lib/services/msgraph-factory.ts
Normal file
42
lib/services/msgraph-factory.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { MsGraphClient, MsGraphClientConfig } from './msgraph-client';
|
||||
|
||||
let msGraphClientInstance: MsGraphClient | null = null;
|
||||
|
||||
/**
|
||||
* Check if Microsoft Graph credentials are configured
|
||||
*/
|
||||
export function isMsgraphConfigured(): boolean {
|
||||
return !!(
|
||||
process.env.MSGRAPH_CLIENT_ID &&
|
||||
process.env.MSGRAPH_CLIENT_SECRET &&
|
||||
process.env.MSGRAPH_TENANT_ID
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create Microsoft Graph client singleton
|
||||
*/
|
||||
export function getMsgraphClient(): MsGraphClient {
|
||||
if (!msGraphClientInstance) {
|
||||
const config: MsGraphClientConfig = {
|
||||
tenantId: process.env.MSGRAPH_TENANT_ID || '',
|
||||
clientId: process.env.MSGRAPH_CLIENT_ID || '',
|
||||
clientSecret: process.env.MSGRAPH_CLIENT_SECRET || '',
|
||||
};
|
||||
|
||||
if (!config.tenantId || !config.clientId || !config.clientSecret) {
|
||||
throw new Error(
|
||||
'Microsoft Graph credentials missing. Set MSGRAPH_CLIENT_ID, MSGRAPH_CLIENT_SECRET, and MSGRAPH_TENANT_ID.'
|
||||
);
|
||||
}
|
||||
|
||||
msGraphClientInstance = new MsGraphClient(config);
|
||||
console.log('[MSGRAPH] Client initialized');
|
||||
}
|
||||
|
||||
return msGraphClientInstance;
|
||||
}
|
||||
|
||||
export function resetMsgraphClient(): void {
|
||||
msGraphClientInstance = null;
|
||||
}
|
||||
|
|
@ -7,15 +7,21 @@ import cron, { ScheduledTask } from 'node-cron';
|
|||
import { SyncService, createSyncService } from './sync-service';
|
||||
import { postgresClient } from './postgres-client';
|
||||
import { AutotaskClient } from './autotask-client';
|
||||
import { EntityType, SyncType } from '../types/sync';
|
||||
import { VeeamSyncService } from './veeam-sync-service';
|
||||
import { VeeamRpoService } from './veeam-rpo-service';
|
||||
import { EngagementSyncService } from './engagement-sync-service';
|
||||
import { isMsgraphConfigured } from './msgraph-factory';
|
||||
import { ZoomSyncService } from './zoom-sync-service';
|
||||
import { isZoomConfigured } from './zoom-factory';
|
||||
import { MorningSummaryService } from './morning-summary-service';
|
||||
|
||||
export interface ScheduleConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
cron_expression: string;
|
||||
sync_type: 'incremental' | 'full' | 'veeam-incremental' | 'veeam-full' | 'veeam-rpo-check';
|
||||
sync_type: 'incremental' | 'full' | 'veeam-incremental' | 'veeam-full' | 'veeam-rpo-check' | 'contract-services' | 'engagement-daily' | 'zoom-daily' | 'morning-summary';
|
||||
years_back?: number;
|
||||
is_enabled: boolean;
|
||||
last_run?: Date;
|
||||
|
|
@ -40,6 +46,8 @@ class SyncScheduler {
|
|||
private syncService: SyncService;
|
||||
private _veeamSyncService: VeeamSyncService | null = null;
|
||||
private _veeamRpoService: VeeamRpoService | null = null;
|
||||
private _engagementSyncService: EngagementSyncService | null = null;
|
||||
private _zoomSyncService: ZoomSyncService | null = null;
|
||||
|
||||
private getVeeamSyncService(): VeeamSyncService {
|
||||
if (!this._veeamSyncService) {
|
||||
|
|
@ -55,6 +63,28 @@ class SyncScheduler {
|
|||
return this._veeamRpoService;
|
||||
}
|
||||
|
||||
private getEngagementSyncService(): EngagementSyncService {
|
||||
if (!this._engagementSyncService) {
|
||||
this._engagementSyncService = new EngagementSyncService();
|
||||
}
|
||||
return this._engagementSyncService;
|
||||
}
|
||||
|
||||
private _morningSummaryService?: MorningSummaryService;
|
||||
private getMorningSummaryService(): MorningSummaryService {
|
||||
if (!this._morningSummaryService) {
|
||||
this._morningSummaryService = new MorningSummaryService();
|
||||
}
|
||||
return this._morningSummaryService;
|
||||
}
|
||||
|
||||
private getZoomSyncService(): ZoomSyncService {
|
||||
if (!this._zoomSyncService) {
|
||||
this._zoomSyncService = new ZoomSyncService();
|
||||
}
|
||||
return this._zoomSyncService;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Create sync service instance
|
||||
const autotaskClient = new AutotaskClient({
|
||||
|
|
@ -105,7 +135,7 @@ class SyncScheduler {
|
|||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
cron_expression VARCHAR(50) NOT NULL,
|
||||
sync_type VARCHAR(30) NOT NULL CHECK (sync_type IN ('incremental', 'full', 'veeam-incremental', 'veeam-full')),
|
||||
sync_type VARCHAR(30) NOT NULL,
|
||||
years_back INTEGER DEFAULT 2,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
last_run TIMESTAMP,
|
||||
|
|
@ -180,6 +210,38 @@ class SyncScheduler {
|
|||
sync_type: 'veeam-rpo-check',
|
||||
is_enabled: false,
|
||||
},
|
||||
{
|
||||
id: 'contract-services',
|
||||
name: 'Contract Services Sync',
|
||||
description: 'Syncs Autotask contract service lines (service catalog items per contract) daily at 4 AM',
|
||||
cron_expression: '0 4 * * *',
|
||||
sync_type: 'contract-services',
|
||||
is_enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'engagement-daily',
|
||||
name: 'Engagement Daily Sync',
|
||||
description: 'Syncs Microsoft Graph Teams and email activity for employee engagement dashboard daily at 6 AM',
|
||||
cron_expression: '0 6 * * *',
|
||||
sync_type: 'engagement-daily',
|
||||
is_enabled: false,
|
||||
},
|
||||
{
|
||||
id: 'zoom-daily',
|
||||
name: 'Zoom Daily Sync',
|
||||
description: 'Syncs Zoom Phone call logs and meeting data daily at 6 AM',
|
||||
cron_expression: '0 6 * * *',
|
||||
sync_type: 'zoom-daily',
|
||||
is_enabled: false,
|
||||
},
|
||||
{
|
||||
id: 'morning-summary',
|
||||
name: 'Morning NOC Summary',
|
||||
description: 'Posts a Zabbix overnight summary Adaptive Card to configured Teams channel webhooks at 6:30 AM Mon–Fri',
|
||||
cron_expression: '30 6 * * 1-5',
|
||||
sync_type: 'morning-summary',
|
||||
is_enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
for (const schedule of defaultSchedules) {
|
||||
|
|
@ -289,6 +351,22 @@ class SyncScheduler {
|
|||
await this.getVeeamSyncService().fullSync('scheduled');
|
||||
} else if (config.sync_type === 'veeam-rpo-check') {
|
||||
await this.getVeeamRpoService().runCheck();
|
||||
} else if (config.sync_type === 'contract-services') {
|
||||
await this.syncService.syncEntities([EntityType.AUTOTASK_SERVICES, EntityType.CONTRACT_SERVICES], SyncType.ENTITY_SPECIFIC, 'scheduled');
|
||||
} else if (config.sync_type === 'engagement-daily') {
|
||||
if (isMsgraphConfigured()) {
|
||||
await this.getEngagementSyncService().sync();
|
||||
} else {
|
||||
console.log('[SCHEDULER] Skipping engagement sync — Microsoft Graph not configured');
|
||||
}
|
||||
} else if (config.sync_type === 'zoom-daily') {
|
||||
if (isZoomConfigured()) {
|
||||
await this.getZoomSyncService().sync();
|
||||
} else {
|
||||
console.log('[SCHEDULER] Skipping Zoom sync — Zoom credentials not configured');
|
||||
}
|
||||
} else if (config.sync_type === 'morning-summary') {
|
||||
await this.getMorningSummaryService().run();
|
||||
} else if (config.sync_type === 'incremental') {
|
||||
await this.syncService.incrementalSync('scheduled');
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import {
|
|||
ZabbixHostCreateParams,
|
||||
ZabbixHostUpdateParams,
|
||||
ZabbixRpcResponse,
|
||||
ZabbixProblem,
|
||||
ZabbixEvent,
|
||||
} from '@/lib/types/zabbix';
|
||||
|
||||
export type { ZabbixHostTag } from '@/lib/types/zabbix';
|
||||
|
|
@ -116,6 +118,119 @@ export class ZabbixClient {
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all hosts with full detail: interfaces, tags, macros, groups.
|
||||
*/
|
||||
async getHosts(): Promise<ZabbixHost[]> {
|
||||
return this.rpc<ZabbixHost[]>('host.get', {
|
||||
output: 'extend',
|
||||
selectInterfaces: 'extend',
|
||||
selectTags: 'extend',
|
||||
selectMacros: 'extend',
|
||||
selectGroups: 'extend',
|
||||
selectParentTemplates: ['templateid', 'host', 'name'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one or more hosts by their hostids.
|
||||
*/
|
||||
async deleteHosts(hostids: string[]): Promise<void> {
|
||||
await this.rpc<{ hostids: string[] }>('host.delete', hostids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get open problems with hosts inline.
|
||||
* Only returns problems whose trigger is linked to at least one enabled host.
|
||||
* severities: 2=Warning,3=Average,4=High,5=Disaster
|
||||
*/
|
||||
async getOpenProblems(minSeverity = 2): Promise<ZabbixProblem[]> {
|
||||
return this.rpc<ZabbixProblem[]>('problem.get', {
|
||||
output: 'extend',
|
||||
selectAcknowledges: 'extend',
|
||||
selectSuppressionData: 'extend',
|
||||
severities: [2, 3, 4, 5].filter(s => s >= minSeverity),
|
||||
recent: true,
|
||||
sortfield: 'eventid',
|
||||
sortorder: 'DESC',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get problems that were resolved within the given window.
|
||||
* Uses problem.get with time_from/time_till (filters on problem clock) and
|
||||
* r_eventid IS SET (meaning the problem has a recovery event = resolved).
|
||||
* Returns problems only — r_clock is the resolution timestamp.
|
||||
*/
|
||||
async getResolvedEvents(from: Date, to: Date): Promise<ZabbixEvent[]> {
|
||||
const fromSec = Math.floor(from.getTime() / 1000);
|
||||
const toSec = Math.floor(to.getTime() / 1000);
|
||||
|
||||
// event.get with value:1 returns PROBLEM trigger events.
|
||||
// time_from/time_till filter on event creation (problem start) time.
|
||||
// We then keep only those that have an r_clock (= resolved) within the window.
|
||||
const events = await this.rpc<ZabbixEvent[]>('event.get', {
|
||||
output: 'extend',
|
||||
source: 0,
|
||||
object: 0,
|
||||
value: 1,
|
||||
time_from: fromSec,
|
||||
time_till: toSec,
|
||||
severities: [2, 3, 4, 5],
|
||||
sortfield: 'eventid',
|
||||
sortorder: 'DESC',
|
||||
limit: 500,
|
||||
});
|
||||
|
||||
return events.filter(e => e.r_eventid && e.r_eventid !== '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch group names for a specific list of hostids.
|
||||
* Returns a Map<hostid, groupName[]> for client name resolution.
|
||||
*/
|
||||
async getHostGroupMap(hostids: string[]): Promise<Map<string, string[]>> {
|
||||
if (hostids.length === 0) return new Map();
|
||||
const hosts = await this.rpc<Array<{
|
||||
hostid: string;
|
||||
groups: Array<{ groupid: string; name: string }>;
|
||||
}>>('host.get', {
|
||||
output: ['hostid'],
|
||||
hostids,
|
||||
selectGroups: ['groupid', 'name'],
|
||||
});
|
||||
|
||||
const map = new Map<string, string[]>();
|
||||
for (const h of hosts) {
|
||||
map.set(h.hostid, (h.groups ?? []).map((g: { name: string }) => g.name));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a list of triggerids, return a map of triggerid → { hostid, hostName }
|
||||
* filtered to only enabled hosts (status '0').
|
||||
* Triggerids with no enabled host are omitted from the map.
|
||||
*/
|
||||
async getTriggerEnabledHosts(triggerids: string[]): Promise<Map<string, { hostid: string; hostName: string }>> {
|
||||
if (triggerids.length === 0) return new Map();
|
||||
const triggers = await this.rpc<Array<{
|
||||
triggerid: string;
|
||||
hosts: Array<{ hostid: string; name: string; status: string }>;
|
||||
}>>('trigger.get', {
|
||||
output: ['triggerid'],
|
||||
triggerids,
|
||||
selectHosts: ['hostid', 'name', 'status'],
|
||||
});
|
||||
|
||||
const map = new Map<string, { hostid: string; hostName: string }>();
|
||||
for (const t of triggers) {
|
||||
const enabled = (t.hosts ?? []).find(h => h.status === '0');
|
||||
if (enabled) map.set(t.triggerid, { hostid: enabled.hostid, hostName: enabled.name });
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a Zabbix host. Idempotent — looks up by host name first.
|
||||
* Returns the hostid and whether the host was created or updated.
|
||||
|
|
|
|||
206
lib/services/zabbix-wan-utils.ts
Normal file
206
lib/services/zabbix-wan-utils.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
/**
|
||||
* Shared utilities for Zabbix WAN host management.
|
||||
* Used by both the RMM discovery sync and the manual host creation endpoints.
|
||||
*/
|
||||
|
||||
import { ZabbixClient } from '@/lib/services/zabbix-client';
|
||||
import { ZabbixHostMacro, ZabbixHostTag, ZabbixHostCreateParams } from '@/lib/types/zabbix';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ISP info type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IspInfo {
|
||||
isp: string; // "Comcast Cable Communications, LLC"
|
||||
asn: string; // "AS7922"
|
||||
city: string;
|
||||
region: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zabbix host technical name sanitization
|
||||
// Zabbix rejects: + ' , . & ( ) and other special chars in the `host` field.
|
||||
// We sanitize to alphanumeric, spaces, hyphens, underscores only.
|
||||
// The display `name` field is left as-is (accepts any UTF-8).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function sanitizeHostname(name: string): string {
|
||||
return name
|
||||
.replace(/[^a-zA-Z0-9 \-_]/g, '') // strip disallowed chars
|
||||
.replace(/\s+/g, ' ') // collapse multiple spaces
|
||||
.trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ISP lookup via ipinfo.io (free, no key required for basic fields)
|
||||
// Results are cached within the module lifetime to avoid duplicate lookups.
|
||||
// Call clearIspCache() at the start of each request if needed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ispCache = new Map<string, IspInfo | null>();
|
||||
|
||||
export function clearIspCache(): void {
|
||||
ispCache.clear();
|
||||
}
|
||||
|
||||
export async function lookupIsp(ip: string): Promise<IspInfo | null> {
|
||||
if (ispCache.has(ip)) return ispCache.get(ip)!;
|
||||
|
||||
try {
|
||||
const token = process.env.IPINFO_TOKEN;
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`https://ipinfo.io/${ip}/json`, {
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(6000),
|
||||
});
|
||||
if (!res.ok) { ispCache.set(ip, null); return null; }
|
||||
|
||||
const data = await res.json();
|
||||
// org field format: "AS7922 Comcast Cable Communications, LLC"
|
||||
const org: string = data.org ?? '';
|
||||
const m = org.match(/^(AS\d+)\s+(.+)$/);
|
||||
|
||||
const info: IspInfo = {
|
||||
isp: m ? m[2] : org,
|
||||
asn: m ? m[1] : '',
|
||||
city: data.city ?? '',
|
||||
region: data.region ?? '',
|
||||
country: data.country ?? '',
|
||||
};
|
||||
ispCache.set(ip, info);
|
||||
return info;
|
||||
} catch {
|
||||
ispCache.set(ip, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build the full Zabbix host upsert params from common inputs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BuildHostParamsInput {
|
||||
siteName: string;
|
||||
wanIp: string;
|
||||
companyId?: number;
|
||||
companyName?: string;
|
||||
rmmSiteUid?: string;
|
||||
ispInfo: IspInfo | null;
|
||||
multiWan?: boolean;
|
||||
allIps?: string[];
|
||||
singleDeviceFallback?: boolean;
|
||||
onlineDeviceCount?: number;
|
||||
source?: string; // tag value for "source" (default "datto-rmm")
|
||||
icmpTemplateId: string | null;
|
||||
globalGroupId: string;
|
||||
zabbix: ZabbixClient;
|
||||
}
|
||||
|
||||
export async function buildHostParams(input: BuildHostParamsInput): Promise<ZabbixHostCreateParams> {
|
||||
const {
|
||||
siteName, wanIp, companyId, companyName, rmmSiteUid,
|
||||
ispInfo, multiWan, allIps, singleDeviceFallback,
|
||||
onlineDeviceCount, source, icmpTemplateId, globalGroupId, zabbix,
|
||||
} = input;
|
||||
|
||||
const templates = icmpTemplateId ? [{ templateid: icmpTemplateId }] : undefined;
|
||||
|
||||
// Build groups: always global, + per-client, + per-ISP
|
||||
const groups: Array<{ groupid: string }> = [{ groupid: globalGroupId }];
|
||||
|
||||
if (companyName) {
|
||||
const clientGroupId = await zabbix.ensureHostGroup(`Clients/${companyName}`);
|
||||
groups.push({ groupid: clientGroupId });
|
||||
}
|
||||
if (ispInfo?.isp) {
|
||||
const ispGroupId = await zabbix.ensureHostGroup(`ISP/${ispInfo.isp}`);
|
||||
groups.push({ groupid: ispGroupId });
|
||||
}
|
||||
|
||||
// Build macros: Autotask identity + ISP context
|
||||
const macros: ZabbixHostMacro[] = [];
|
||||
if (companyId && companyName) {
|
||||
macros.push(
|
||||
{ macro: '{$AUTOTASK_COMPANY_ID}', value: String(companyId), description: 'Autotask company ID' },
|
||||
{ macro: '{$AUTOTASK_COMPANY_NAME}', value: companyName, description: 'Autotask company name' },
|
||||
);
|
||||
}
|
||||
if (rmmSiteUid) {
|
||||
macros.push(
|
||||
{ macro: '{$RMM_SITE_UID}', value: rmmSiteUid, description: 'Datto RMM site UID' },
|
||||
);
|
||||
}
|
||||
if (ispInfo) {
|
||||
macros.push(
|
||||
{ macro: '{$ISP_NAME}', value: ispInfo.isp, description: 'ISP / carrier name' },
|
||||
{ macro: '{$ASN}', value: ispInfo.asn, description: 'Autonomous System Number' },
|
||||
{ macro: '{$ISP_CITY}', value: ispInfo.city, description: 'City (from IP geolocation)' },
|
||||
{ macro: '{$ISP_REGION}', value: ispInfo.region, description: 'Region (from IP geolocation)' },
|
||||
{ macro: '{$ISP_COUNTRY}', value: ispInfo.country, description: 'Country code (from IP geolocation)' },
|
||||
);
|
||||
}
|
||||
if (multiWan && allIps) {
|
||||
macros.push({ macro: '{$MULTI_WAN_IPS}', value: allIps.join(', '), description: 'All public IPs seen (multi-WAN site)' });
|
||||
}
|
||||
|
||||
// Build tags: for dashboard filtering and problem correlation
|
||||
const sourceTag = source ?? 'datto-rmm';
|
||||
const tags: ZabbixHostTag[] = [{ tag: 'source', value: sourceTag }];
|
||||
if (companyName) {
|
||||
tags.push({ tag: 'client', value: companyName });
|
||||
}
|
||||
if (ispInfo?.isp) {
|
||||
tags.push({ tag: 'isp', value: ispInfo.isp });
|
||||
}
|
||||
if (ispInfo?.asn) {
|
||||
tags.push({ tag: 'asn', value: ispInfo.asn });
|
||||
}
|
||||
if (multiWan) {
|
||||
tags.push({ tag: 'multi-wan', value: 'true' });
|
||||
}
|
||||
if (singleDeviceFallback) {
|
||||
tags.push({ tag: 'single-device-fallback', value: 'true' });
|
||||
}
|
||||
|
||||
const descParts = [
|
||||
onlineDeviceCount != null
|
||||
? `Datto RMM site – WAN IP from ${onlineDeviceCount} online devices`
|
||||
: `Manual host – WAN IP ${wanIp}`,
|
||||
ispInfo ? `ISP: ${ispInfo.isp} (${ispInfo.asn}) — ${ispInfo.city}, ${ispInfo.region}, ${ispInfo.country}` : null,
|
||||
multiWan && allIps ? `Multi-WAN detected: ${allIps.join(', ')}` : null,
|
||||
singleDeviceFallback ? `Note: IP sourced from single device (no multi-device confirmation)` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
return {
|
||||
host: sanitizeHostname(siteName),
|
||||
name: siteName,
|
||||
description: descParts,
|
||||
interfaces: [{ type: 1, main: 1, useip: 1, ip: wanIp, dns: '', port: '10050' }],
|
||||
groups,
|
||||
templates,
|
||||
macros: macros.length > 0 ? macros : undefined,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discover ICMP template — tries multiple common names
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ICMP_TEMPLATE_NAMES = [
|
||||
'ICMP Ping',
|
||||
'Template Module ICMP Ping',
|
||||
'Template Module ICMP Ping by Zabbix agent',
|
||||
];
|
||||
|
||||
export async function discoverIcmpTemplate(zabbix: ZabbixClient): Promise<string | null> {
|
||||
for (const name of ICMP_TEMPLATE_NAMES) {
|
||||
const tmpl = await zabbix.findTemplate(name);
|
||||
if (tmpl) return tmpl.templateid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
264
lib/services/zoom-client.ts
Normal file
264
lib/services/zoom-client.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
/**
|
||||
* Zoom Server-to-Server OAuth API Client
|
||||
* Requires: ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET
|
||||
*/
|
||||
|
||||
export interface ZoomUser {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ZoomCallLog {
|
||||
id: string;
|
||||
call_id: string;
|
||||
caller_number: string;
|
||||
caller_name: string;
|
||||
callee_number: string;
|
||||
callee_name: string;
|
||||
direction: 'inbound' | 'outbound' | 'internal';
|
||||
result: string; // 'Call connected', 'Missed', 'Voicemail', etc.
|
||||
date_time: string; // ISO 8601 — Zoom Phone API uses date_time, not start_time
|
||||
duration: number; // seconds
|
||||
}
|
||||
|
||||
export interface ZoomMeeting {
|
||||
id: string | number;
|
||||
uuid: string;
|
||||
host_id: string;
|
||||
host_email?: string;
|
||||
topic: string;
|
||||
start_time: string;
|
||||
end_time?: string;
|
||||
duration: number; // minutes
|
||||
participants_count?: number;
|
||||
}
|
||||
|
||||
export interface ZoomMeetingParticipant {
|
||||
id?: string;
|
||||
user_id?: string;
|
||||
name: string;
|
||||
user_email: string;
|
||||
duration: number; // seconds
|
||||
join_time: string;
|
||||
leave_time: string;
|
||||
}
|
||||
|
||||
export interface ZoomClientConfig {
|
||||
accountId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export class ZoomClient {
|
||||
private config: ZoomClientConfig;
|
||||
private accessToken: string | null = null;
|
||||
private tokenExpiry: number = 0;
|
||||
private readonly baseUrl = 'https://api.zoom.us/v2';
|
||||
|
||||
constructor(config: ZoomClientConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
private async getToken(): Promise<string> {
|
||||
// Refresh 5 minutes before expiry
|
||||
if (this.accessToken && Date.now() < this.tokenExpiry - 300000) {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
const credentials = Buffer.from(
|
||||
`${this.config.clientId}:${this.config.clientSecret}`
|
||||
).toString('base64');
|
||||
|
||||
const url = `https://zoom.us/oauth/token?grant_type=account_credentials&account_id=${encodeURIComponent(this.config.accountId)}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${credentials}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Zoom token request failed: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
this.accessToken = data.access_token;
|
||||
this.tokenExpiry = Date.now() + data.expires_in * 1000;
|
||||
return this.accessToken!;
|
||||
}
|
||||
|
||||
private async fetchJson<T>(path: string, retries = 0): Promise<T> {
|
||||
const token = await this.getToken();
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
if (retries >= 5) {
|
||||
throw new Error(`Zoom rate limit hit after ${retries} retries: ${path}`);
|
||||
}
|
||||
const delay = Math.pow(2, retries) * 1000;
|
||||
console.warn(`[ZOOM] Rate limited on ${path}, retrying in ${delay}ms`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
return this.fetchJson<T>(path, retries + 1);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Zoom API error ${res.status} for ${path}: ${text}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active Zoom users (paginated via next_page_token)
|
||||
*/
|
||||
async getUsers(): Promise<ZoomUser[]> {
|
||||
const users: ZoomUser[] = [];
|
||||
let nextPageToken = '';
|
||||
|
||||
do {
|
||||
const params = new URLSearchParams({
|
||||
status: 'active',
|
||||
page_size: '300',
|
||||
});
|
||||
if (nextPageToken) params.set('next_page_token', nextPageToken);
|
||||
|
||||
const data = await this.fetchJson<{
|
||||
users: ZoomUser[];
|
||||
next_page_token?: string;
|
||||
}>(`/users?${params}`);
|
||||
|
||||
users.push(...(data.users ?? []));
|
||||
nextPageToken = data.next_page_token ?? '';
|
||||
} while (nextPageToken);
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get phone call logs for a user in a date range (paginated)
|
||||
* from/to: ISO date strings 'YYYY-MM-DD'
|
||||
*/
|
||||
async getUserCallLogs(
|
||||
zoomUserId: string,
|
||||
from: string,
|
||||
to: string
|
||||
): Promise<ZoomCallLog[]> {
|
||||
const calls: ZoomCallLog[] = [];
|
||||
let nextPageToken = '';
|
||||
|
||||
do {
|
||||
const params = new URLSearchParams({
|
||||
from,
|
||||
to,
|
||||
page_size: '300',
|
||||
type: 'all',
|
||||
});
|
||||
if (nextPageToken) params.set('next_page_token', nextPageToken);
|
||||
|
||||
let data: { call_logs?: ZoomCallLog[]; next_page_token?: string };
|
||||
try {
|
||||
data = await this.fetchJson<{
|
||||
call_logs?: ZoomCallLog[];
|
||||
next_page_token?: string;
|
||||
}>(`/phone/users/${encodeURIComponent(zoomUserId)}/call_logs?${params}`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// 400 = user has no Zoom Phone license — skip silently
|
||||
if (msg.includes('400') || msg.includes('404')) {
|
||||
break;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
calls.push(...(data.call_logs ?? []));
|
||||
nextPageToken = data.next_page_token ?? '';
|
||||
} while (nextPageToken);
|
||||
|
||||
return calls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get past meetings for a user in a date range (paginated)
|
||||
* Uses the Reports API — requires role or admin scope
|
||||
*/
|
||||
async getUserPastMeetings(
|
||||
zoomUserId: string,
|
||||
from: string,
|
||||
to: string
|
||||
): Promise<ZoomMeeting[]> {
|
||||
const meetings: ZoomMeeting[] = [];
|
||||
let nextPageToken = '';
|
||||
|
||||
do {
|
||||
const params = new URLSearchParams({
|
||||
type: 'past',
|
||||
from,
|
||||
to,
|
||||
page_size: '300',
|
||||
});
|
||||
if (nextPageToken) params.set('next_page_token', nextPageToken);
|
||||
|
||||
let data: { meetings?: ZoomMeeting[]; next_page_token?: string };
|
||||
try {
|
||||
data = await this.fetchJson<{
|
||||
meetings?: ZoomMeeting[];
|
||||
next_page_token?: string;
|
||||
}>(`/report/users/${encodeURIComponent(zoomUserId)}/meetings?${params}`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes('400') || msg.includes('404')) {
|
||||
break;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
meetings.push(...(data.meetings ?? []));
|
||||
nextPageToken = data.next_page_token ?? '';
|
||||
} while (nextPageToken);
|
||||
|
||||
return meetings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get participants for a past meeting (Zoom limitation: last 30 days only)
|
||||
*/
|
||||
async getMeetingParticipants(meetingId: string): Promise<ZoomMeetingParticipant[]> {
|
||||
const participants: ZoomMeetingParticipant[] = [];
|
||||
let nextPageToken = '';
|
||||
|
||||
do {
|
||||
const params = new URLSearchParams({ page_size: '300' });
|
||||
if (nextPageToken) params.set('next_page_token', nextPageToken);
|
||||
|
||||
let data: { participants?: ZoomMeetingParticipant[]; next_page_token?: string };
|
||||
try {
|
||||
data = await this.fetchJson<{
|
||||
participants?: ZoomMeetingParticipant[];
|
||||
next_page_token?: string;
|
||||
}>(`/past_meetings/${encodeURIComponent(meetingId)}/participants?${params}`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes('400') || msg.includes('404') || msg.includes('3001')) {
|
||||
break;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
participants.push(...(data.participants ?? []));
|
||||
nextPageToken = data.next_page_token ?? '';
|
||||
} while (nextPageToken);
|
||||
|
||||
return participants;
|
||||
}
|
||||
}
|
||||
42
lib/services/zoom-factory.ts
Normal file
42
lib/services/zoom-factory.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { ZoomClient, ZoomClientConfig } from './zoom-client';
|
||||
|
||||
let zoomClientInstance: ZoomClient | null = null;
|
||||
|
||||
/**
|
||||
* Check if Zoom Server-to-Server OAuth credentials are configured
|
||||
*/
|
||||
export function isZoomConfigured(): boolean {
|
||||
return !!(
|
||||
process.env.ZOOM_ACCOUNT_ID &&
|
||||
process.env.ZOOM_CLIENT_ID &&
|
||||
process.env.ZOOM_CLIENT_SECRET
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create Zoom client singleton
|
||||
*/
|
||||
export function getZoomClient(): ZoomClient {
|
||||
if (!zoomClientInstance) {
|
||||
const config: ZoomClientConfig = {
|
||||
accountId: process.env.ZOOM_ACCOUNT_ID || '',
|
||||
clientId: process.env.ZOOM_CLIENT_ID || '',
|
||||
clientSecret: process.env.ZOOM_CLIENT_SECRET || '',
|
||||
};
|
||||
|
||||
if (!config.accountId || !config.clientId || !config.clientSecret) {
|
||||
throw new Error(
|
||||
'Zoom credentials missing. Set ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, and ZOOM_CLIENT_SECRET.'
|
||||
);
|
||||
}
|
||||
|
||||
zoomClientInstance = new ZoomClient(config);
|
||||
console.log('[ZOOM] Client initialized');
|
||||
}
|
||||
|
||||
return zoomClientInstance;
|
||||
}
|
||||
|
||||
export function resetZoomClient(): void {
|
||||
zoomClientInstance = null;
|
||||
}
|
||||
387
lib/services/zoom-sync-service.ts
Normal file
387
lib/services/zoom-sync-service.ts
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
/**
|
||||
* Zoom Sync Service
|
||||
* Orchestrates Zoom Phone + Meetings → PostgreSQL sync
|
||||
* Cross-references calls/meetings with Autotask contacts and companies
|
||||
*/
|
||||
|
||||
import { getZoomClient } from './zoom-factory';
|
||||
import { postgresClient } from './postgres-client';
|
||||
|
||||
const CALL_WINDOW_DAYS = 90;
|
||||
const MEETING_WINDOW_DAYS = 90;
|
||||
const PARTICIPANT_WINDOW_DAYS = 30; // Zoom API limitation
|
||||
|
||||
/**
|
||||
* Strip all non-digit characters and return last 10 digits.
|
||||
* Handles country code variations (e.g. +1 prefix).
|
||||
*/
|
||||
function normalizePhone(phone: string | null | undefined): string {
|
||||
if (!phone) return '';
|
||||
const digits = phone.replace(/\D/g, '');
|
||||
return digits.slice(-10);
|
||||
}
|
||||
|
||||
function toIsoDate(d: Date): string {
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
export class ZoomSyncService {
|
||||
private syncInProgress = false;
|
||||
|
||||
isSyncInProgress(): boolean {
|
||||
return this.syncInProgress;
|
||||
}
|
||||
|
||||
async sync(): Promise<{
|
||||
usersUpserted: number;
|
||||
callsUpserted: number;
|
||||
callsMatched: number;
|
||||
meetingsUpserted: number;
|
||||
participantsUpserted: number;
|
||||
}> {
|
||||
if (this.syncInProgress) {
|
||||
throw new Error('Zoom sync already in progress');
|
||||
}
|
||||
|
||||
this.syncInProgress = true;
|
||||
const startTime = Date.now();
|
||||
console.log('[ZOOM-SYNC] Starting sync...');
|
||||
|
||||
try {
|
||||
const client = getZoomClient();
|
||||
|
||||
// ─── Step 1: Sync Zoom users ───────────────────────────────────────
|
||||
console.log('[ZOOM-SYNC] Fetching Zoom users...');
|
||||
const zoomUsers = await client.getUsers();
|
||||
|
||||
// Load set of resource emails from DB (for filtering)
|
||||
const resourceEmailsResult = await postgresClient.query(
|
||||
`SELECT LOWER(email) as email FROM resources
|
||||
WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL`
|
||||
);
|
||||
const resourceEmails = new Set<string>(
|
||||
resourceEmailsResult.rows.map((r: { email: string }) => r.email)
|
||||
);
|
||||
|
||||
let usersUpserted = 0;
|
||||
const activeZoomUsers = zoomUsers.filter(
|
||||
u => u.email && resourceEmails.has(u.email.toLowerCase())
|
||||
);
|
||||
|
||||
for (const user of activeZoomUsers) {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO zoom_users (zoom_id, email, display_name, is_active, synced_at)
|
||||
VALUES ($1, $2, $3, true, NOW())
|
||||
ON CONFLICT (zoom_id) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
display_name = EXCLUDED.display_name,
|
||||
is_active = true,
|
||||
synced_at = NOW()`,
|
||||
[user.id, user.email.toLowerCase(), user.display_name]
|
||||
);
|
||||
usersUpserted++;
|
||||
}
|
||||
console.log(`[ZOOM-SYNC] Upserted ${usersUpserted} Zoom users (filtered to resource emails)`);
|
||||
|
||||
// ─── Step 2: Build phone match index from Autotask contacts ───────
|
||||
console.log('[ZOOM-SYNC] Building phone index from Autotask contacts...');
|
||||
|
||||
const contactsResult = await postgresClient.query(
|
||||
`SELECT id, company_id, phone, mobile_phone, alternate_phone
|
||||
FROM contacts
|
||||
WHERE (is_deleted = false OR is_deleted IS NULL)`
|
||||
);
|
||||
|
||||
// Map: normalized 10-digit phone → { contactId, companyId }
|
||||
const phoneIndex = new Map<string, { contactId: number; companyId: number | null }>();
|
||||
for (const row of contactsResult.rows) {
|
||||
for (const field of ['phone', 'mobile_phone', 'alternate_phone'] as const) {
|
||||
const norm = normalizePhone(row[field]);
|
||||
if (norm && !phoneIndex.has(norm)) {
|
||||
phoneIndex.set(norm, { contactId: row.id, companyId: row.company_id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also index company phones → companyId (fallback when no contact match)
|
||||
const companiesResult = await postgresClient.query(
|
||||
`SELECT id, phone FROM companies WHERE phone IS NOT NULL`
|
||||
);
|
||||
const companyPhoneIndex = new Map<string, number>();
|
||||
for (const row of companiesResult.rows) {
|
||||
const norm = normalizePhone(row.phone);
|
||||
if (norm && !companyPhoneIndex.has(norm)) {
|
||||
companyPhoneIndex.set(norm, row.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Internal phone set — to skip contact matching for internal calls
|
||||
const internalNumbers = new Set<string>();
|
||||
|
||||
// ─── Step 3: Sync call logs (90-day window) ────────────────────────
|
||||
const callFrom = toIsoDate(new Date(Date.now() - CALL_WINDOW_DAYS * 24 * 60 * 60 * 1000));
|
||||
const callTo = toIsoDate(new Date());
|
||||
|
||||
let callsUpserted = 0;
|
||||
let callsMatched = 0;
|
||||
|
||||
for (const user of activeZoomUsers) {
|
||||
console.log(`[ZOOM-SYNC] Fetching call logs for ${user.email}...`);
|
||||
const calls = await client.getUserCallLogs(user.id, callFrom, callTo);
|
||||
|
||||
for (const call of calls) {
|
||||
// Determine direction and the other-party number
|
||||
const direction = call.direction;
|
||||
let otherPartyNumber: string;
|
||||
let otherPartyName: string;
|
||||
|
||||
if (direction === 'outbound') {
|
||||
otherPartyNumber = call.callee_number;
|
||||
otherPartyName = call.callee_name || '';
|
||||
} else if (direction === 'inbound') {
|
||||
otherPartyNumber = call.caller_number;
|
||||
otherPartyName = call.caller_name || '';
|
||||
} else {
|
||||
// internal — record but skip contact matching
|
||||
otherPartyNumber = call.caller_number || call.callee_number || '';
|
||||
otherPartyName = call.caller_name || call.callee_name || '';
|
||||
internalNumbers.add(normalizePhone(otherPartyNumber));
|
||||
}
|
||||
|
||||
const callId = call.id || call.call_id;
|
||||
const normOther = normalizePhone(otherPartyNumber);
|
||||
|
||||
// Map call result → status
|
||||
const result = (call.result || '').toLowerCase();
|
||||
let callStatus = 'completed';
|
||||
if (result.includes('missed') || result.includes('no answer')) callStatus = 'missed';
|
||||
else if (result.includes('voicemail')) callStatus = 'voicemail';
|
||||
else if (result.includes('busy')) callStatus = 'busy';
|
||||
else if (result.includes('failed') || result.includes('cancel')) callStatus = 'failed';
|
||||
|
||||
// Cross-reference: contact → company
|
||||
let matchedContactId: number | null = null;
|
||||
let matchedCompanyId: number | null = null;
|
||||
|
||||
if (direction !== 'internal' && normOther) {
|
||||
const contactMatch = phoneIndex.get(normOther);
|
||||
if (contactMatch) {
|
||||
matchedContactId = contactMatch.contactId;
|
||||
matchedCompanyId = contactMatch.companyId;
|
||||
} else {
|
||||
const companyId = companyPhoneIndex.get(normOther);
|
||||
if (companyId) matchedCompanyId = companyId;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedContactId || matchedCompanyId) callsMatched++;
|
||||
|
||||
await postgresClient.query(
|
||||
`INSERT INTO zoom_calls (
|
||||
zoom_call_id, resource_email, direction, call_status,
|
||||
other_party_number, other_party_name,
|
||||
start_time, duration_seconds,
|
||||
matched_contact_id, matched_company_id, synced_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW())
|
||||
ON CONFLICT (zoom_call_id) DO UPDATE SET
|
||||
start_time = EXCLUDED.start_time,
|
||||
duration_seconds = EXCLUDED.duration_seconds,
|
||||
call_status = EXCLUDED.call_status,
|
||||
matched_contact_id = EXCLUDED.matched_contact_id,
|
||||
matched_company_id = EXCLUDED.matched_company_id,
|
||||
synced_at = NOW()`,
|
||||
[
|
||||
callId,
|
||||
user.email.toLowerCase(),
|
||||
direction,
|
||||
callStatus,
|
||||
otherPartyNumber || null,
|
||||
otherPartyName || null,
|
||||
call.date_time ? new Date(call.date_time) : null,
|
||||
call.duration ?? null,
|
||||
matchedContactId,
|
||||
matchedCompanyId,
|
||||
]
|
||||
);
|
||||
callsUpserted++;
|
||||
}
|
||||
}
|
||||
console.log(`[ZOOM-SYNC] Calls: ${callsUpserted} upserted, ${callsMatched} matched to contacts/companies`);
|
||||
|
||||
// ─── Step 4: Sync past meetings (90-day window) ───────────────────
|
||||
const meetingFrom = toIsoDate(new Date(Date.now() - MEETING_WINDOW_DAYS * 24 * 60 * 60 * 1000));
|
||||
const meetingTo = toIsoDate(new Date());
|
||||
|
||||
// Build map of zoom_id → email for host resolution
|
||||
const zoomIdToEmail = new Map<string, string>(
|
||||
activeZoomUsers.map(u => [u.id, u.email.toLowerCase()])
|
||||
);
|
||||
|
||||
let meetingsUpserted = 0;
|
||||
|
||||
// meeting DB id map: zoom_meeting_id → db id (for participant insert)
|
||||
const meetingDbIds = new Map<string, number>();
|
||||
|
||||
for (const user of activeZoomUsers) {
|
||||
console.log(`[ZOOM-SYNC] Fetching past meetings for ${user.email}...`);
|
||||
const meetings = await client.getUserPastMeetings(user.id, meetingFrom, meetingTo);
|
||||
|
||||
for (const meeting of meetings) {
|
||||
const meetingId = String(meeting.id);
|
||||
const hostEmail = meeting.host_email ?? zoomIdToEmail.get(meeting.host_id) ?? user.email.toLowerCase();
|
||||
const endTime = meeting.end_time
|
||||
? new Date(meeting.end_time)
|
||||
: meeting.start_time
|
||||
? new Date(new Date(meeting.start_time).getTime() + (meeting.duration ?? 0) * 60000)
|
||||
: null;
|
||||
|
||||
const result = await postgresClient.query(
|
||||
`INSERT INTO zoom_meetings (
|
||||
zoom_meeting_id, zoom_meeting_uuid, host_email,
|
||||
topic, start_time, end_time, duration_minutes,
|
||||
participant_count, client_participant_count, has_client_attendees,
|
||||
synced_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,0,false,NOW())
|
||||
ON CONFLICT (zoom_meeting_id) DO UPDATE SET
|
||||
zoom_meeting_uuid = EXCLUDED.zoom_meeting_uuid,
|
||||
host_email = EXCLUDED.host_email,
|
||||
topic = EXCLUDED.topic,
|
||||
start_time = EXCLUDED.start_time,
|
||||
end_time = EXCLUDED.end_time,
|
||||
duration_minutes = EXCLUDED.duration_minutes,
|
||||
participant_count = EXCLUDED.participant_count,
|
||||
synced_at = NOW()
|
||||
RETURNING id`,
|
||||
[
|
||||
meetingId,
|
||||
meeting.uuid || null,
|
||||
hostEmail,
|
||||
meeting.topic || null,
|
||||
meeting.start_time ? new Date(meeting.start_time) : null,
|
||||
endTime,
|
||||
meeting.duration ?? null,
|
||||
meeting.participants_count ?? 0,
|
||||
]
|
||||
);
|
||||
|
||||
if (result.rows[0]) {
|
||||
meetingDbIds.set(meetingId, result.rows[0].id);
|
||||
meetingsUpserted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[ZOOM-SYNC] Meetings upserted: ${meetingsUpserted}`);
|
||||
|
||||
// ─── Step 5: Sync meeting participants (30-day window) ────────────
|
||||
const participantCutoff = new Date(Date.now() - PARTICIPANT_WINDOW_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Build contact email index
|
||||
const contactEmailResult = await postgresClient.query(
|
||||
`SELECT id, company_id, LOWER(email_address) as email1,
|
||||
LOWER(email_address2) as email2, LOWER(email_address3) as email3
|
||||
FROM contacts
|
||||
WHERE (is_deleted = false OR is_deleted IS NULL)`
|
||||
);
|
||||
const contactEmailIndex = new Map<string, { contactId: number; companyId: number | null }>();
|
||||
for (const row of contactEmailResult.rows) {
|
||||
for (const e of [row.email1, row.email2, row.email3]) {
|
||||
if (e && !contactEmailIndex.has(e)) {
|
||||
contactEmailIndex.set(e, { contactId: row.id, companyId: row.company_id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let participantsUpserted = 0;
|
||||
|
||||
// Process meetings within the 30-day participant window
|
||||
const recentMeetingIds = await postgresClient.query(
|
||||
`SELECT id, zoom_meeting_id FROM zoom_meetings
|
||||
WHERE start_time >= $1`,
|
||||
[participantCutoff]
|
||||
);
|
||||
|
||||
for (const meetingRow of recentMeetingIds.rows) {
|
||||
const dbMeetingId: number = meetingRow.id;
|
||||
const zoomMeetingId: string = meetingRow.zoom_meeting_id;
|
||||
|
||||
// Clear existing participants for this meeting (re-sync)
|
||||
await postgresClient.query(
|
||||
`DELETE FROM zoom_meeting_participants WHERE meeting_id = $1`,
|
||||
[dbMeetingId]
|
||||
);
|
||||
|
||||
const participants = await client.getMeetingParticipants(zoomMeetingId);
|
||||
|
||||
let clientParticipantCount = 0;
|
||||
let hasClientAttendees = false;
|
||||
|
||||
for (const p of participants) {
|
||||
const email = (p.user_email || '').toLowerCase();
|
||||
const isInternal = email ? resourceEmails.has(email) : false;
|
||||
|
||||
let matchedContactId: number | null = null;
|
||||
let matchedCompanyId: number | null = null;
|
||||
|
||||
if (email && !isInternal) {
|
||||
const match = contactEmailIndex.get(email);
|
||||
if (match) {
|
||||
matchedContactId = match.contactId;
|
||||
matchedCompanyId = match.companyId;
|
||||
clientParticipantCount++;
|
||||
hasClientAttendees = true;
|
||||
}
|
||||
}
|
||||
|
||||
await postgresClient.query(
|
||||
`INSERT INTO zoom_meeting_participants (
|
||||
meeting_id, participant_email, participant_name,
|
||||
duration_seconds, matched_contact_id, matched_company_id,
|
||||
is_internal, created_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,NOW())`,
|
||||
[
|
||||
dbMeetingId,
|
||||
email || null,
|
||||
p.name || null,
|
||||
p.duration ?? null,
|
||||
matchedContactId,
|
||||
matchedCompanyId,
|
||||
isInternal,
|
||||
]
|
||||
);
|
||||
participantsUpserted++;
|
||||
}
|
||||
|
||||
// Update meeting with participant stats
|
||||
await postgresClient.query(
|
||||
`UPDATE zoom_meetings
|
||||
SET client_participant_count = $2, has_client_attendees = $3
|
||||
WHERE id = $1`,
|
||||
[dbMeetingId, clientParticipantCount, hasClientAttendees]
|
||||
);
|
||||
}
|
||||
console.log(`[ZOOM-SYNC] Participants upserted: ${participantsUpserted}`);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.log(`[ZOOM-SYNC] Done in ${duration}ms`);
|
||||
|
||||
return {
|
||||
usersUpserted,
|
||||
callsUpserted,
|
||||
callsMatched,
|
||||
meetingsUpserted,
|
||||
participantsUpserted,
|
||||
};
|
||||
} finally {
|
||||
this.syncInProgress = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: ZoomSyncService | null = null;
|
||||
|
||||
export function getZoomSyncService(): ZoomSyncService {
|
||||
if (!_instance) {
|
||||
_instance = new ZoomSyncService();
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
|
@ -281,46 +281,33 @@ export interface AutotaskTimeEntry {
|
|||
resourceID: number;
|
||||
ticketID?: number;
|
||||
taskID?: number;
|
||||
projectID?: number;
|
||||
companyID?: number;
|
||||
dateWorked: string; // ISO date string - Autotask uses dateWorked
|
||||
hoursWorked: number; // Hours worked on this entry
|
||||
summaryNotes?: string; // Autotask uses summaryNotes not notes
|
||||
internalNotes?: string;
|
||||
title?: string;
|
||||
type?: number;
|
||||
startDateTime?: string; // ISO datetime string
|
||||
endDateTime?: string; // ISO datetime string
|
||||
billable?: boolean;
|
||||
billingRate?: number;
|
||||
billingRateCurrencyID?: number;
|
||||
costRate?: number;
|
||||
costRateCurrencyID?: number;
|
||||
cost?: number;
|
||||
costCurrencyID?: number;
|
||||
revenue?: number;
|
||||
revenueCurrencyID?: number;
|
||||
margin?: number;
|
||||
marginCurrencyID?: number;
|
||||
approved?: boolean;
|
||||
approvedByResourceID?: number;
|
||||
approvedDateTime?: string; // ISO datetime string
|
||||
nonBillable?: boolean;
|
||||
contractID?: number;
|
||||
contractServiceID?: number;
|
||||
contractServiceBundleID?: number;
|
||||
dateWorked: string; // ISO date string
|
||||
hoursWorked: number;
|
||||
hoursToBill?: number; // Read-only: actual hours that will be billed (may differ from hoursWorked due to contract caps)
|
||||
summaryNotes?: string;
|
||||
internalNotes?: string;
|
||||
isInternalNotesVisibleToComanaged?: boolean;
|
||||
timeEntryType?: number; // Read-only
|
||||
startDateTime?: string;
|
||||
endDateTime?: string;
|
||||
offsetHours?: number;
|
||||
isNonBillable?: boolean; // True if this entry is non-billable
|
||||
showOnInvoice?: boolean;
|
||||
billingCodeID?: number;
|
||||
internalBillingCodeID?: number;
|
||||
billingApprovalLevelMostRecent?: number; // Read-only
|
||||
billingApprovalResourceID?: number;
|
||||
billingApprovalDateTime?: string;
|
||||
roleID?: number;
|
||||
departmentID?: number;
|
||||
locationID?: number;
|
||||
allocationCodeID?: number;
|
||||
impProjectScheduleID?: number;
|
||||
impProjectScheduleTaskID?: number;
|
||||
apiVendorID?: number;
|
||||
createDate: string; // ISO datetime string
|
||||
lastModifiedDate?: string; // ISO datetime string
|
||||
userDefinedFields?: Array<{
|
||||
name: string;
|
||||
value: any;
|
||||
}>;
|
||||
createDateTime?: string;
|
||||
creatorUserID?: number;
|
||||
lastModifiedDateTime?: string;
|
||||
lastModifiedUserID?: number;
|
||||
impersonatorCreatorResourceID?: number;
|
||||
impersonatorUpdaterResourceID?: number;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export enum EntityType {
|
|||
CONFIGURATION_ITEMS = 'configuration_items',
|
||||
CONTACTS = 'contacts',
|
||||
CONTRACTS = 'contracts',
|
||||
CONTRACT_SERVICES = 'contract_services',
|
||||
AUTOTASK_SERVICES = 'autotask_services',
|
||||
TIME_ENTRIES = 'time_entries',
|
||||
TICKET_NOTES = 'ticket_notes',
|
||||
}
|
||||
|
|
@ -164,6 +166,8 @@ export const ENTITY_DEPENDENCIES: Record<EntityType, EntityType[]> = {
|
|||
[EntityType.TASKS]: [EntityType.RESOURCES, EntityType.PROJECTS, EntityType.TICKETS], // Depends on resources, projects, tickets
|
||||
[EntityType.CONFIGURATION_ITEMS]: [EntityType.COMPANIES, EntityType.CONTACTS], // Depends on companies and contacts
|
||||
[EntityType.CONTRACTS]: [EntityType.COMPANIES, EntityType.CONTACTS], // Depends on companies and contacts
|
||||
[EntityType.CONTRACT_SERVICES]: [EntityType.CONTRACTS], // Depends on contracts
|
||||
[EntityType.AUTOTASK_SERVICES]: [], // No dependencies — standalone lookup
|
||||
[EntityType.BILLING_ITEMS]: [EntityType.COMPANIES, EntityType.TASKS, EntityType.TICKETS, EntityType.PROJECTS], // Depends on multiple entities
|
||||
[EntityType.TIME_ENTRIES]: [EntityType.COMPANIES, EntityType.RESOURCES, EntityType.CONTACTS, EntityType.PROJECTS, EntityType.TASKS, EntityType.TICKETS], // Depends on many entities
|
||||
[EntityType.TICKET_NOTES]: [EntityType.TICKETS], // Depends on tickets
|
||||
|
|
|
|||
|
|
@ -10,10 +10,13 @@ export interface ZabbixHost {
|
|||
host: string;
|
||||
name: string;
|
||||
status: string;
|
||||
description?: string;
|
||||
interfaces?: ZabbixHostInterface[];
|
||||
groups?: ZabbixHostGroup[];
|
||||
templates?: ZabbixTemplate[];
|
||||
description?: string;
|
||||
parentTemplates?: ZabbixTemplate[];
|
||||
macros?: ZabbixHostMacro[];
|
||||
tags?: ZabbixHostTag[];
|
||||
}
|
||||
|
||||
export interface ZabbixHostInterface {
|
||||
|
|
@ -69,6 +72,30 @@ export interface ZabbixHostUpdateParams {
|
|||
tags?: ZabbixHostTag[];
|
||||
}
|
||||
|
||||
export interface ZabbixProblem {
|
||||
eventid: string;
|
||||
objectid: string;
|
||||
name: string;
|
||||
severity: string;
|
||||
clock: string;
|
||||
acknowledged: string;
|
||||
acknowledges?: Array<{ acknowledgeid: string; userid: string; clock: string; message: string }>;
|
||||
suppressed?: string;
|
||||
r_eventid?: string;
|
||||
hosts?: Array<{ hostid: string; name: string; status: string }>;
|
||||
}
|
||||
|
||||
export interface ZabbixEvent {
|
||||
eventid: string;
|
||||
objectid: string;
|
||||
name: string;
|
||||
severity: string;
|
||||
clock: string;
|
||||
r_eventid?: string;
|
||||
r_clock?: string;
|
||||
hosts?: Array<{ hostid: string; name: string; status: string }>;
|
||||
}
|
||||
|
||||
export interface ZabbixRpcResponse<T> {
|
||||
jsonrpc: string;
|
||||
result?: T;
|
||||
|
|
|
|||
|
|
@ -51,6 +51,12 @@ export function mapAutotaskToDatabase(
|
|||
case EntityType.CONTRACTS:
|
||||
mapped = mapContract(data);
|
||||
break;
|
||||
case EntityType.CONTRACT_SERVICES:
|
||||
mapped = mapContractService(data);
|
||||
break;
|
||||
case EntityType.AUTOTASK_SERVICES:
|
||||
mapped = mapAutotaskService(data);
|
||||
break;
|
||||
case EntityType.BILLING_ITEMS:
|
||||
mapped = mapBillingItem(data);
|
||||
break;
|
||||
|
|
@ -518,6 +524,41 @@ function mapContract(data: any): Record<string, any> {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Autotask Service (catalog item) entity
|
||||
*/
|
||||
function mapAutotaskService(data: any): Record<string, any> {
|
||||
return {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
unit_price: data.unitPrice,
|
||||
unit_cost: data.unitCost,
|
||||
period_type: data.periodType,
|
||||
is_active: data.isActive !== undefined ? data.isActive : true,
|
||||
synced_at: new Date(),
|
||||
is_deleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Contract Service entity
|
||||
*/
|
||||
function mapContractService(data: any): Record<string, any> {
|
||||
return {
|
||||
id: data.id,
|
||||
contract_id: data.contractID,
|
||||
service_id: data.serviceID,
|
||||
unit_price: data.unitPrice,
|
||||
unit_cost: data.unitCost,
|
||||
adjusted_price: data.internalCurrencyAdjustedPrice,
|
||||
invoice_description: data.invoiceDescription,
|
||||
internal_currency_price: data.internalCurrencyUnitPrice,
|
||||
synced_at: new Date(),
|
||||
is_deleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Billing Item entity
|
||||
*/
|
||||
|
|
@ -558,45 +599,32 @@ function mapBillingItem(data: any): Record<string, any> {
|
|||
* Map Time Entry entity
|
||||
*/
|
||||
function mapTimeEntry(data: any): Record<string, any> {
|
||||
// Autotask uses isNonBillable; derive billable from it
|
||||
const isNonBillable = data.isNonBillable;
|
||||
const billable = isNonBillable != null ? !isNonBillable : null;
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
resource_id: data.resourceID,
|
||||
ticket_id: data.ticketID,
|
||||
task_id: data.taskID,
|
||||
project_id: data.projectID,
|
||||
company_id: data.companyID,
|
||||
entry_date: data.dateWorked, // Autotask API returns dateWorked
|
||||
hours_worked: data.hoursWorked, // Autotask API returns hoursWorked
|
||||
notes: data.summaryNotes, // Autotask uses summaryNotes
|
||||
internal_notes: data.internalNotes,
|
||||
title: data.title,
|
||||
type: data.type,
|
||||
start_date_time: data.startDateTime,
|
||||
end_date_time: data.endDateTime,
|
||||
billable: data.billable, // Autotask returns billable
|
||||
billing_rate: data.billingRate,
|
||||
billing_rate_currency_id: data.billingRateCurrencyID,
|
||||
cost_rate: data.costRate,
|
||||
cost_rate_currency_id: data.costRateCurrencyID,
|
||||
cost: data.cost,
|
||||
cost_currency_id: data.costCurrencyID,
|
||||
revenue: data.revenue,
|
||||
revenue_currency_id: data.revenueCurrencyID,
|
||||
margin: data.margin,
|
||||
margin_currency_id: data.marginCurrencyID,
|
||||
approved: data.approved,
|
||||
approved_by_resource_id: data.approvedByResourceID,
|
||||
approved_date_time: data.approvedDateTime,
|
||||
non_billable: data.nonBillable,
|
||||
contract_id: data.contractID,
|
||||
contract_service_id: data.contractServiceID,
|
||||
contract_service_bundle_id: data.contractServiceBundleID,
|
||||
entry_date: data.dateWorked,
|
||||
hours_worked: data.hoursWorked,
|
||||
hours_to_bill: data.hoursToBill,
|
||||
notes: data.summaryNotes,
|
||||
internal_notes: data.internalNotes,
|
||||
type: data.timeEntryType,
|
||||
start_date_time: data.startDateTime,
|
||||
end_date_time: data.endDateTime,
|
||||
billable,
|
||||
non_billable: isNonBillable,
|
||||
allocation_code_id: data.billingCodeID,
|
||||
approved_by_resource_id: data.billingApprovalResourceID,
|
||||
approved_date_time: data.billingApprovalDateTime,
|
||||
role_id: data.roleID,
|
||||
department_id: data.departmentID,
|
||||
location_id: data.locationID,
|
||||
allocation_code_id: data.allocationCodeID,
|
||||
imp_project_schedule_id: data.impProjectScheduleID,
|
||||
imp_project_schedule_task_id: data.impProjectScheduleTaskID,
|
||||
api_vendor_id: data.apiVendorID,
|
||||
synced_at: new Date(),
|
||||
is_deleted: false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -65,11 +65,27 @@ export function getAllEntitiesInOrder(): EntityType[] {
|
|||
EntityType.TASKS,
|
||||
EntityType.CONFIGURATION_ITEMS,
|
||||
EntityType.CONTRACTS,
|
||||
EntityType.CONTRACT_SERVICES,
|
||||
EntityType.AUTOTASK_SERVICES,
|
||||
EntityType.BILLING_ITEMS,
|
||||
EntityType.TIME_ENTRIES,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build filter for contract services (requires contractID filter — fetch all via contractIDs)
|
||||
* @returns Query filter array for active contract services
|
||||
*/
|
||||
export function buildContractServicesFilter(): Array<{ field: string; op: string; value: any }> {
|
||||
return [
|
||||
{
|
||||
field: 'contractID',
|
||||
op: 'gt',
|
||||
value: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get table name for entity type
|
||||
* @param entity Entity type
|
||||
|
|
@ -102,6 +118,8 @@ export function getAutotaskEntityName(entity: EntityType): string {
|
|||
[EntityType.CONFIGURATION_ITEMS]: 'ConfigurationItems',
|
||||
[EntityType.CONTACTS]: 'Contacts',
|
||||
[EntityType.CONTRACTS]: 'Contracts',
|
||||
[EntityType.CONTRACT_SERVICES]: 'ContractServices',
|
||||
[EntityType.AUTOTASK_SERVICES]: 'Services',
|
||||
[EntityType.TIME_ENTRIES]: 'TimeEntries',
|
||||
[EntityType.TICKET_NOTES]: 'TicketNotes',
|
||||
};
|
||||
|
|
@ -141,6 +159,8 @@ export function getLastModifiedField(entity: EntityType): string {
|
|||
[EntityType.CONFIGURATION_ITEMS]: 'lastModifiedTime',
|
||||
[EntityType.CONTACTS]: 'lastModifiedDate',
|
||||
[EntityType.CONTRACTS]: 'lastModifiedDateTime',
|
||||
[EntityType.CONTRACT_SERVICES]: 'lastModifiedDate',
|
||||
[EntityType.AUTOTASK_SERVICES]: 'lastModifiedDate',
|
||||
[EntityType.BILLING_ITEMS]: 'itemDate',
|
||||
[EntityType.TIME_ENTRIES]: 'dateWorked',
|
||||
[EntityType.TICKET_NOTES]: 'lastActivityDate',
|
||||
|
|
@ -171,6 +191,8 @@ export function getActiveField(entity: EntityType): string | null {
|
|||
[EntityType.CONFIGURATION_ITEMS]: 'isActive',
|
||||
[EntityType.CONTACTS]: 'isActive',
|
||||
[EntityType.CONTRACTS]: null, // Use status field instead
|
||||
[EntityType.CONTRACT_SERVICES]: null,
|
||||
[EntityType.AUTOTASK_SERVICES]: 'isActive',
|
||||
[EntityType.BILLING_ITEMS]: null,
|
||||
[EntityType.TIME_ENTRIES]: null, // Time entries don't have active status
|
||||
[EntityType.TICKET_NOTES]: null, // Ticket notes don't have active status
|
||||
|
|
@ -258,7 +280,9 @@ export function buildDateRangeFilter(
|
|||
[EntityType.TIME_ENTRIES]: 'createDate', // TimeEntry uses createDate for filtering
|
||||
[EntityType.PROJECTS]: 'startDateTime',
|
||||
[EntityType.BILLING_ITEMS]: 'itemDate',
|
||||
[EntityType.CONTRACTS]: 'startDate', // Contracts use startDate
|
||||
[EntityType.CONTRACTS]: 'startDate',
|
||||
[EntityType.CONTRACT_SERVICES]: null,
|
||||
[EntityType.AUTOTASK_SERVICES]: null,
|
||||
[EntityType.COMPANIES]: null,
|
||||
[EntityType.RESOURCES]: null,
|
||||
[EntityType.CONTACTS]: null,
|
||||
|
|
@ -456,6 +480,8 @@ export function getEntityDisplayName(entity: EntityType): string {
|
|||
[EntityType.CONFIGURATION_ITEMS]: 'Configuration Items',
|
||||
[EntityType.CONTACTS]: 'Contacts',
|
||||
[EntityType.CONTRACTS]: 'Contracts',
|
||||
[EntityType.CONTRACT_SERVICES]: 'Contract Services',
|
||||
[EntityType.AUTOTASK_SERVICES]: 'Autotask Services',
|
||||
[EntityType.TIME_ENTRIES]: 'Time Entries',
|
||||
[EntityType.TICKET_NOTES]: 'Ticket Notes',
|
||||
};
|
||||
|
|
|
|||
36
migrations/040_create_contract_services_table.sql
Normal file
36
migrations/040_create_contract_services_table.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
CREATE TABLE IF NOT EXISTS contract_services (
|
||||
id BIGINT PRIMARY KEY,
|
||||
contract_id BIGINT NOT NULL,
|
||||
company_id BIGINT,
|
||||
service_id BIGINT,
|
||||
service_name TEXT,
|
||||
description TEXT,
|
||||
period_type INTEGER,
|
||||
unit_price NUMERIC(15,2),
|
||||
unit_cost NUMERIC(15,2),
|
||||
discount_percent NUMERIC(5,2),
|
||||
adjusted_price NUMERIC(15,2),
|
||||
quantity NUMERIC(10,2),
|
||||
invoice_description TEXT,
|
||||
start_date DATE,
|
||||
end_date DATE,
|
||||
internal_currency_price NUMERIC(15,2),
|
||||
create_date TIMESTAMPTZ,
|
||||
last_modified_date TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_contract_services_contract_id ON contract_services(contract_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_contract_services_company_id ON contract_services(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_contract_services_service_name ON contract_services(service_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_contract_services_is_deleted ON contract_services(is_deleted);
|
||||
|
||||
ALTER TABLE contract_services
|
||||
DROP CONSTRAINT IF EXISTS fk_contract_services_contract;
|
||||
ALTER TABLE contract_services
|
||||
ADD CONSTRAINT fk_contract_services_contract
|
||||
FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE;
|
||||
37
migrations/041_create_engagement_tables.sql
Normal file
37
migrations/041_create_engagement_tables.sql
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
-- Migration 041: Create engagement tables for Microsoft Graph activity data
|
||||
|
||||
CREATE TABLE IF NOT EXISTS graph_users (
|
||||
id VARCHAR(255) PRIMARY KEY, -- Azure AD object ID
|
||||
display_name VARCHAR(255),
|
||||
email VARCHAR(255) UNIQUE, -- matches resources.email
|
||||
job_title VARCHAR(255),
|
||||
department VARCHAR(255),
|
||||
account_enabled BOOLEAN DEFAULT true,
|
||||
last_activity_date DATE,
|
||||
synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS engagement_snapshots (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_email VARCHAR(255) NOT NULL,
|
||||
period_type VARCHAR(10) NOT NULL, -- 'D7', 'D30', 'D90'
|
||||
period_end DATE NOT NULL,
|
||||
-- Teams
|
||||
teams_chat_messages INT DEFAULT 0,
|
||||
teams_private_messages INT DEFAULT 0,
|
||||
teams_calls INT DEFAULT 0,
|
||||
teams_meetings_attended INT DEFAULT 0,
|
||||
teams_meetings_organized INT DEFAULT 0,
|
||||
-- Email
|
||||
emails_sent INT DEFAULT 0,
|
||||
emails_received INT DEFAULT 0,
|
||||
emails_read INT DEFAULT 0,
|
||||
-- Last activity
|
||||
last_activity_date DATE,
|
||||
synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_email, period_type, period_end)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_engagement_snapshots_email ON engagement_snapshots(user_email);
|
||||
CREATE INDEX IF NOT EXISTS idx_engagement_snapshots_period ON engagement_snapshots(period_type, period_end);
|
||||
CREATE INDEX IF NOT EXISTS idx_graph_users_email ON graph_users(email);
|
||||
6
migrations/042_add_engagement_calendar_columns.sql
Normal file
6
migrations/042_add_engagement_calendar_columns.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
-- Migration 042: Add meeting time and external meeting columns to engagement_snapshots
|
||||
|
||||
ALTER TABLE engagement_snapshots
|
||||
ADD COLUMN IF NOT EXISTS audio_duration_seconds INT DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS meeting_duration_seconds INT DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS meetings_with_external INT DEFAULT 0;
|
||||
5
migrations/043_add_hours_to_bill_to_time_entries.sql
Normal file
5
migrations/043_add_hours_to_bill_to_time_entries.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- Add hours_to_bill column to time_entries
|
||||
-- Autotask's read-only hoursToBill field: actual hours that will be billed,
|
||||
-- which may differ from hours_worked due to contract caps or billing rules.
|
||||
ALTER TABLE time_entries
|
||||
ADD COLUMN IF NOT EXISTS hours_to_bill DECIMAL(10, 2);
|
||||
4
migrations/044_add_contract_id_to_time_entries.sql
Normal file
4
migrations/044_add_contract_id_to_time_entries.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
-- Add contract_id column to time_entries
|
||||
-- Autotask contractID field on time entries
|
||||
ALTER TABLE time_entries
|
||||
ADD COLUMN IF NOT EXISTS contract_id BIGINT;
|
||||
58
migrations/045_create_zoom_tables.sql
Normal file
58
migrations/045_create_zoom_tables.sql
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
-- Zoom Phone and Meetings Integration Tables
|
||||
-- Supports cross-referencing with Autotask contacts/companies
|
||||
|
||||
CREATE TABLE IF NOT EXISTS zoom_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
zoom_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE,
|
||||
display_name VARCHAR(255),
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
synced_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS zoom_calls (
|
||||
id SERIAL PRIMARY KEY,
|
||||
zoom_call_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
resource_email VARCHAR(255),
|
||||
direction VARCHAR(20),
|
||||
call_status VARCHAR(20),
|
||||
other_party_number VARCHAR(50),
|
||||
other_party_name VARCHAR(255),
|
||||
start_time TIMESTAMPTZ,
|
||||
duration_seconds INTEGER,
|
||||
matched_contact_id BIGINT REFERENCES contacts(id),
|
||||
matched_company_id BIGINT REFERENCES companies(id),
|
||||
synced_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS zoom_meetings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
zoom_meeting_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
zoom_meeting_uuid VARCHAR(500),
|
||||
host_email VARCHAR(255),
|
||||
topic VARCHAR(500),
|
||||
start_time TIMESTAMPTZ,
|
||||
end_time TIMESTAMPTZ,
|
||||
duration_minutes INTEGER,
|
||||
participant_count INTEGER DEFAULT 0,
|
||||
client_participant_count INTEGER DEFAULT 0,
|
||||
has_client_attendees BOOLEAN DEFAULT false,
|
||||
synced_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS zoom_meeting_participants (
|
||||
id SERIAL PRIMARY KEY,
|
||||
meeting_id INTEGER NOT NULL REFERENCES zoom_meetings(id) ON DELETE CASCADE,
|
||||
participant_email VARCHAR(255),
|
||||
participant_name VARCHAR(255),
|
||||
duration_seconds INTEGER,
|
||||
matched_contact_id BIGINT REFERENCES contacts(id),
|
||||
matched_company_id BIGINT REFERENCES companies(id),
|
||||
is_internal BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_zoom_calls_resource_email_time ON zoom_calls(resource_email, start_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_zoom_calls_matched_company ON zoom_calls(matched_company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_zoom_meetings_host_email_time ON zoom_meetings(host_email, start_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_zoom_meeting_participants_email ON zoom_meeting_participants(participant_email);
|
||||
28
migrations/046_create_teams_meetings_table.sql
Normal file
28
migrations/046_create_teams_meetings_table.sql
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
CREATE TABLE IF NOT EXISTS teams_meetings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
graph_event_id VARCHAR(500) NOT NULL,
|
||||
user_email VARCHAR(255) NOT NULL,
|
||||
subject VARCHAR(500),
|
||||
start_time TIMESTAMPTZ,
|
||||
end_time TIMESTAMPTZ,
|
||||
duration_minutes INTEGER,
|
||||
is_online_meeting BOOLEAN DEFAULT false,
|
||||
attendee_count INTEGER DEFAULT 0,
|
||||
client_attendee_count INTEGER DEFAULT 0,
|
||||
has_client_attendees BOOLEAN DEFAULT false,
|
||||
synced_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_email, graph_event_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS teams_meeting_attendees (
|
||||
id SERIAL PRIMARY KEY,
|
||||
meeting_id INTEGER NOT NULL REFERENCES teams_meetings(id) ON DELETE CASCADE,
|
||||
attendee_email VARCHAR(255),
|
||||
attendee_name VARCHAR(255),
|
||||
matched_contact_id BIGINT REFERENCES contacts(id),
|
||||
matched_company_id BIGINT REFERENCES companies(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_teams_meetings_user_email_time ON teams_meetings(user_email, start_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_teams_meeting_attendees_meeting_id ON teams_meeting_attendees(meeting_id);
|
||||
6
migrations/047_add_after_hours_messages.sql
Normal file
6
migrations/047_add_after_hours_messages.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
-- Migration 047: Add after-hours message tracking to engagement snapshots
|
||||
-- Counts Teams messages sent by the employee between 5:30 PM and 7:00 AM UTC.
|
||||
-- Populated during sync if the Azure AD app has the Chat.Read.All permission.
|
||||
|
||||
ALTER TABLE engagement_snapshots
|
||||
ADD COLUMN IF NOT EXISTS after_hours_messages INT DEFAULT 0;
|
||||
45
migrations/048_create_morning_summary_tables.sql
Normal file
45
migrations/048_create_morning_summary_tables.sql
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
-- Morning Summary: webhooks, config, and run history
|
||||
|
||||
CREATE TABLE IF NOT EXISTS morning_summary_webhooks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
webhook_url TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
last_delivered_at TIMESTAMPTZ,
|
||||
last_status TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS morning_summary_config (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
weekend_suppression BOOLEAN NOT NULL DEFAULT true,
|
||||
monday_extended_window BOOLEAN NOT NULL DEFAULT true,
|
||||
severity_filter INTEGER NOT NULL DEFAULT 2,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO morning_summary_config (id, weekend_suppression, monday_extended_window, severity_filter)
|
||||
VALUES (1, true, true, 2)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS morning_summaries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
window_from TIMESTAMPTZ NOT NULL,
|
||||
window_to TIMESTAMPTZ NOT NULL,
|
||||
open_count INTEGER NOT NULL DEFAULT 0,
|
||||
resolved_count INTEGER NOT NULL DEFAULT 0,
|
||||
mttr_minutes INTEGER,
|
||||
clients_affected TEXT[] NOT NULL DEFAULT '{}',
|
||||
is_weekend_window BOOLEAN NOT NULL DEFAULT false,
|
||||
card_payload JSONB,
|
||||
delivery_status JSONB NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_morning_summaries_generated_at ON morning_summaries(generated_at DESC);
|
||||
|
||||
INSERT INTO morning_summary_webhooks (label, webhook_url, enabled) VALUES
|
||||
('Technical / On-Call', 'https://appriver3651004158.webhook.office.com/webhookb2/43394fff-d546-440f-ae34-eed04c5bcd5d@1c260a52-b84d-4b7a-a179-44971dbc3949/IncomingWebhook/fe9e1350791f4923bdbd56c076eae6ed/ee54b2ef-4f27-4188-b865-47fda0eae4d1/V2y29hRVISyBYOdDq6dPs-ekvlhqCF6XGE3yUUFxt1dKQ1', true),
|
||||
('Technical / Outages-Issues','https://appriver3651004158.webhook.office.com/webhookb2/43394fff-d546-440f-ae34-eed04c5bcd5d@1c260a52-b84d-4b7a-a179-44971dbc3949/IncomingWebhook/b7cf42691520467986db5a993dcd07b6/ee54b2ef-4f27-4188-b865-47fda0eae4d1/V2kdADKBhv3h3CfOfeIbe3O6HI5hIh4BRVX5UMqIxhLE81', false),
|
||||
('Finance (Testing)', 'https://appriver3651004158.webhook.office.com/webhookb2/033405b3-b02e-4ce5-a200-4a3b9f387c92@1c260a52-b84d-4b7a-a179-44971dbc3949/IncomingWebhook/5fe8882c1c0342a7bbe91d763abbca57/ee54b2ef-4f27-4188-b865-47fda0eae4d1/V2rSVLy80XTdPJlBJVzUBYrMyrTi_Tmts46dA8BZjlPaw1', false)
|
||||
ON CONFLICT DO NOTHING;
|
||||
380
package-lock.json
generated
380
package-lock.json
generated
|
|
@ -44,6 +44,7 @@
|
|||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.70.0",
|
||||
"recharts": "^3.7.0",
|
||||
"redis": "^5.10.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
|
@ -3943,6 +3944,42 @@
|
|||
"@redis/client": "^5.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
|
|
@ -4906,6 +4943,69 @@
|
|||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
|
|
@ -4984,6 +5084,12 @@
|
|||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz",
|
||||
|
|
@ -6435,6 +6541,127 @@
|
|||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
|
|
@ -6529,6 +6756,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
|
|
@ -7052,6 +7285,16 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.45.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.0.tgz",
|
||||
"integrity": "sha512-RArCX+Zea16+R1jg4mH223Z8p/ivbJjIkU3oC6ld2bdUfmDxiCkFYSi9zLOR2anucWJUeH4Djnzgd0im0nD3dw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
|
|
@ -7498,6 +7741,12 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
|
|
@ -8073,6 +8322,16 @@
|
|||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
|
|
@ -8127,6 +8386,15 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ioredis": {
|
||||
"version": "5.9.0",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.0.tgz",
|
||||
|
|
@ -10171,9 +10439,31 @@
|
|||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
|
||||
|
|
@ -10270,6 +10560,36 @@
|
|||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redis": {
|
||||
"version": "5.10.0",
|
||||
"resolved": "https://registry.npmjs.org/redis/-/redis-5.10.0.tgz",
|
||||
|
|
@ -10307,6 +10627,21 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
|
|
@ -10357,6 +10692,12 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
|
|
@ -11123,6 +11464,12 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||
|
|
@ -11532,12 +11879,43 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@
|
|||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.70.0",
|
||||
"recharts": "^3.7.0",
|
||||
"redis": "^5.10.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
|
|
|||
339
tasks/prd-morning-summary-teams.md
Normal file
339
tasks/prd-morning-summary-teams.md
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
# PRD: Morning NOC Summary — Teams Adaptive Card
|
||||
|
||||
**Version:** 1.1
|
||||
**Last updated:** 2026-03-10
|
||||
|
||||
---
|
||||
|
||||
## 1. Introduction / Overview
|
||||
|
||||
Wulf Consulting management needs a daily morning briefing on overnight infrastructure activity
|
||||
across all ~46 client sites monitored by Zabbix. This feature automates that into a rich Teams
|
||||
direct message delivered before the workday starts, with configurable recipients and optional
|
||||
channel escalation actions directly from the card.
|
||||
|
||||
**Goal:** Post a Zabbix-sourced morning summary as a Teams Adaptive Card to one or more
|
||||
configured Teams channel webhooks. The primary targets are the **On-Call** and/or
|
||||
**Outages-Issues** channels in the **Technical** team. The card runs at 6:30 AM ET on
|
||||
weekdays (with a Monday extended window option) and can be triggered on-demand from the
|
||||
Pulse admin UI. No bot registration required — delivery is via incoming webhook URLs stored
|
||||
in Pulse config.
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals
|
||||
|
||||
1. Post a formatted Teams Adaptive Card to configured channel webhook(s) at 6:30 AM Mon–Fri.
|
||||
2. Surface currently-open Zabbix problems (client name, trigger name, duration, severity).
|
||||
3. Surface problems that resolved overnight (during the report window).
|
||||
4. Show headline stats: open count, resolved count, avg MTTR, clients affected.
|
||||
5. Highlight open problems active > 4 hours without acknowledgement ("Needs Attention" flag).
|
||||
6. Support posting to multiple webhooks (e.g. both On-Call and Outages-Issues simultaneously).
|
||||
7. Provide a **manual trigger** in the Pulse admin UI with per-webhook test send capability.
|
||||
8. Support configurable weekend suppression; when Monday is the run day, extend the report
|
||||
window to cover the full weekend (Friday 6 PM → Monday 6:30 AM).
|
||||
9. Persist each generated summary to the Pulse database and show it as a dashboard widget.
|
||||
|
||||
---
|
||||
|
||||
## 3. User Stories
|
||||
|
||||
- **As Tom Carlin / Lorentz Hinrichsen**, I want to see the morning NOC summary posted to
|
||||
the Teams channels I already monitor so I get the briefing without checking a separate tool.
|
||||
- **As a Pulse admin**, I want to configure which channel webhooks receive the summary without
|
||||
touching code or env files.
|
||||
- **As a Pulse admin**, I want to send a test post to a specific webhook to verify formatting
|
||||
before the scheduled run.
|
||||
- **As a Pulse admin**, I want to toggle weekend suppression and Monday extended window.
|
||||
- **As any Pulse user**, I want to see the most recent summary on the Pulse dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 4. Functional Requirements
|
||||
|
||||
### 4.1 Data Collection (Zabbix only — v1)
|
||||
|
||||
1. The system **must** query `problem.get` (with `recent: true`, `selectHosts: extend`,
|
||||
`selectAcknowledges: extend`, `severities: [2,3,4,5]`) to retrieve all currently open
|
||||
problems at run time.
|
||||
2. The system **must** query `event.get` with `value: 0` (recovery events) between
|
||||
`time_from` = window start and `time_to` = run time to retrieve resolved problems.
|
||||
3. The system **must** query `host.get` with `selectHostGroups: ['name']` once per run to
|
||||
build a `hostid → { clientName, hostName }` map using groups prefixed `Clients/`.
|
||||
4. The report window **must** be determined as follows:
|
||||
- **Tuesday–Friday runs:** window start = previous calendar day at 18:00 ET.
|
||||
- **Monday run (weekend mode on):** window start = the preceding Friday at 18:00 ET,
|
||||
covering the full Sat + Sun + Mon pre-6:30 AM period.
|
||||
- **Monday run (weekend mode off):** same as Tue–Fri (Sunday 18:00 ET → Monday 6:30 AM).
|
||||
5. The system **must** calculate:
|
||||
- **Open count** — number of active problems meeting severity filter.
|
||||
- **Resolved count** — recovery events in the window.
|
||||
- **Avg MTTR (minutes)** — mean of `(r_clock - clock)` for resolved events; `null` if none.
|
||||
- **Clients affected** — deduplicated client names from open problems.
|
||||
6. Any open problem where `clock` is > 4 hours before run time **and** `acknowledged` array
|
||||
is empty **must** be flagged `needsAttention: true`.
|
||||
|
||||
### 4.2 Webhook Configuration — Pulse UI
|
||||
|
||||
7. The admin UI **must** provide a **Webhooks** section on `/admin/morning-summary` to manage
|
||||
the list of Teams incoming webhook URLs that receive the summary.
|
||||
8. The admin **must** be able to **add** a webhook by providing:
|
||||
- A display label (e.g. `Technical / On-Call`)
|
||||
- The incoming webhook URL
|
||||
- An enabled toggle
|
||||
9. The admin **must** be able to **remove** or **disable** any webhook.
|
||||
10. Webhooks **must** be stored in Postgres (new table `morning_summary_webhooks`) with:
|
||||
- `id` (serial PK)
|
||||
- `label` (text) — human-friendly name
|
||||
- `webhook_url` (text)
|
||||
- `enabled` (boolean, default true)
|
||||
- `last_delivered_at` (timestamptz, nullable)
|
||||
- `last_status` (text, nullable) — `success` or `failed`
|
||||
- `created_at` (timestamptz)
|
||||
11. The admin UI **must** provide a **"Test"** button next to each webhook that posts the
|
||||
current summary card to that URL only, independent of the scheduled run.
|
||||
12. The test endpoint **must** be `POST /api/notifications/morning-summary/test` with
|
||||
body `{ "webhookId": <id> }`.
|
||||
13. The UI **must** display per-webhook last delivery status inline.
|
||||
|
||||
### 4.3 Teams Adaptive Card — Structure
|
||||
|
||||
14. The card **must** include a header `TextBlock`: `☀️ Morning NOC Summary — {date}`.
|
||||
- On Monday with weekend mode: append ` (Weekend Coverage)` to the header.
|
||||
15. The card **must** include a stat `ColumnSet`: Open (red), Resolved (green), Avg MTTR.
|
||||
16. If there are **open problems**, the card **must** include a red-styled `Container`
|
||||
labelled `🔴 OPEN ISSUES` with a `FactSet`:
|
||||
- **Title:** `{ClientName} / {HostName}`
|
||||
- **Value:** `{TriggerName} — {duration}` (+ ` ⚠️ Needs Attention` if flagged)
|
||||
17. If there are **no open problems**, the card **must** show a green "✅ All Clear" container.
|
||||
18. If there are **resolved overnight** events, the card **must** include a green-styled
|
||||
`Container` labelled `🟢 RESOLVED OVERNIGHT`. If more than 5, show 5 and append
|
||||
`+{N} more — all resolved`.
|
||||
19. The card **must** include three action buttons:
|
||||
- `Open Zabbix` → `https://zabbix.wulfconsulting.cloud`
|
||||
- `Open Pulse` → `https://pulse.wulfconsulting.cloud`
|
||||
- `View Problems` → `https://zabbix.wulfconsulting.cloud/zabbix.php?action=problem.view`
|
||||
20. The card **must** use Adaptive Card schema version `1.4`.
|
||||
21. The card payload **must** be wrapped in the Teams incoming webhook envelope:
|
||||
`{ "type": "message", "attachments": [{ "contentType": "application/vnd.microsoft.card.adaptive", "content": <card> }] }`
|
||||
|
||||
### 4.4 Delivery via Incoming Webhooks
|
||||
|
||||
22. The system **must** POST the Adaptive Card envelope to each enabled webhook URL.
|
||||
23. Delivery **must** be attempted for all enabled webhooks regardless of individual failures.
|
||||
24. Each delivery result (HTTP status, error message) **must** be recorded in
|
||||
`morning_summaries.delivery_status` keyed by webhook ID.
|
||||
25. The send endpoint `POST /api/notifications/morning-summary/send` **must** accept an
|
||||
optional `{ "webhookIds": [1, 2] }` body to target specific webhooks; if omitted, all
|
||||
enabled webhooks are used.
|
||||
26. Incoming webhook URLs are obtained from Teams: channel → connectors → "Incoming Webhook".
|
||||
The admin pastes the URL into the Pulse webhook config UI.
|
||||
|
||||
### 4.5 MS Graph Usage (read-only)
|
||||
|
||||
27. MS Graph is **not** used for delivery in v1 (webhooks handle that).
|
||||
28. MS Graph `getUsers()` **may** still be used in the admin UI to resolve display names when
|
||||
configuring webhook labels, but is not required for the core send flow.
|
||||
29. No `ChatMessage.Send` or `ChannelMessage.Send` Graph permissions are needed for v1.
|
||||
|
||||
### 4.6 Scheduling & Weekend Mode
|
||||
|
||||
33. The job **must** be registered in `SyncScheduler` with cron `30 6 * * 1-5`.
|
||||
34. A `morning_summary_config` table (single-row settings) **must** store:
|
||||
- `weekend_suppression` (boolean) — if true, skip Sat/Sun runs.
|
||||
- `monday_extended_window` (boolean) — if true and today is Monday, set window start
|
||||
to preceding Friday 18:00 ET.
|
||||
- `severity_filter` (int, default 2) — minimum Zabbix severity to include.
|
||||
- `updated_at` (timestamptz)
|
||||
35. The admin UI **must** expose toggles for `weekend_suppression` and `monday_extended_window`
|
||||
with a clear label: _"On Mondays, extend window to cover the full weekend (Fri 6 PM → Mon
|
||||
6:30 AM)"_.
|
||||
36. The admin UI **must** display the next scheduled run time calculated from the cron
|
||||
expression and current settings.
|
||||
|
||||
### 4.7 Manual Trigger — Pulse UI
|
||||
|
||||
37. The admin UI at `/admin/morning-summary` **must** provide:
|
||||
- **Schedule status:** next run time, last run time, last run result.
|
||||
- **"Send Now"** button — fires `POST /api/notifications/morning-summary/send` to all
|
||||
enabled webhooks immediately.
|
||||
- **Per-webhook "Test"** buttons (see §4.2 req 11).
|
||||
- **Weekend mode toggles** (see §4.6 req 35).
|
||||
- **Webhooks section** — add/remove/enable-disable webhook entries (see §4.2).
|
||||
- **Last card preview** — most recent `card_payload` as formatted JSON (collapsed by default).
|
||||
38. The UI **must** show a toast on send and display per-webhook delivery results inline.
|
||||
|
||||
### 4.8 Dashboard Widget & Persistence
|
||||
|
||||
39. Each run (scheduled or manual/test) **must** persist to `morning_summaries`:
|
||||
- `id` (serial PK)
|
||||
- `generated_at` (timestamptz)
|
||||
- `window_from` (timestamptz)
|
||||
- `window_to` (timestamptz)
|
||||
- `open_count` (int)
|
||||
- `resolved_count` (int)
|
||||
- `mttr_minutes` (int, nullable)
|
||||
- `clients_affected` (text[])
|
||||
- `is_weekend_window` (boolean)
|
||||
- `card_payload` (jsonb)
|
||||
- `delivery_status` (jsonb) — `{ [webhookId]: { success: bool, httpStatus?: number, error?: string } }`
|
||||
40. The Pulse home/dashboard **must** display a "Morning Summary" widget with: open count,
|
||||
resolved count, MTTR, window label, last generated timestamp, and a link to the admin
|
||||
page.
|
||||
|
||||
---
|
||||
|
||||
## 5. Non-Goals (Out of Scope for v1)
|
||||
|
||||
- Veeam backup failure data (deferred to v2).
|
||||
- PSA/Autotask ticket correlation and "unmatched alerts" (deferred to v2).
|
||||
- Direct Teams DMs to individual users (requires bot registration — out of scope).
|
||||
- Email or ntfy delivery channels (webhooks only for v1).
|
||||
- ISP outage detection / grouping by ISP.
|
||||
- Per-channel content customization (all webhooks receive the same card).
|
||||
|
||||
---
|
||||
|
||||
## 6. Design Considerations
|
||||
|
||||
### Adaptive Card Layout
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ ☀️ Morning NOC Summary — Mon Mar 16 (Weekend) │
|
||||
├──────────┬────────────┬────────────────────────── │
|
||||
│ 3 Open │ 12 Resolved│ 22m Avg MTTR │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ 🔴 OPEN ISSUES │
|
||||
│ Kuhn's / FW-01 Host Unreachable — 6h 12m ⚠️ │
|
||||
│ ADM / SW-Core High Packet Loss — 2h 5m │
|
||||
│ Seubert / DC-01 Host Unreachable — 1h 20m │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ 🟢 RESOLVED OVERNIGHT │
|
||||
│ Brodaks / RTR-01 Slow Response — 22m │
|
||||
│ +11 more — all resolved │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│[Open Zabbix][Open Pulse][View Problems] │
|
||||
│[📣 Notify On Call] [🚨 Post to Outages] │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- `"style": "attention"` container → red in Teams (open issues).
|
||||
- `"style": "good"` container → green in Teams (resolved).
|
||||
- Duration: `Xh Ym` for ≥ 1 hour, `Xm` for < 1 hour.
|
||||
- Escalation buttons open a Pulse URL in browser; Pulse posts to channel server-side
|
||||
and returns a simple confirmation page.
|
||||
|
||||
### Recipient Management UI (on `/admin/morning-summary`)
|
||||
|
||||
```
|
||||
Recipients
|
||||
┌────────────────────────────┬──────────┬────────────────────┐
|
||||
│ Name / Email │ Status │ Actions │
|
||||
├────────────────────────────┼──────────┼────────────────────┤
|
||||
│ Tom Carlin │ ✅ Last: │ [Test] [Remove] │
|
||||
│ tom@wulfconsulting.com │ today │ │
|
||||
├────────────────────────────┼──────────┼────────────────────┤
|
||||
│ Lorentz Hinrichsen │ ✅ Last: │ [Test] [Remove] │
|
||||
│ lorentz@wulfconsulting.com │ today │ │
|
||||
└────────────────────────────┴──────────┴────────────────────┘
|
||||
[+ Add Recipient ▾] ← searchable dropdown of tenant users
|
||||
```
|
||||
|
||||
### Webhook Delivery Flow
|
||||
|
||||
```
|
||||
// For each enabled webhook in morning_summary_webhooks:
|
||||
POST {webhook_url}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"type": "message",
|
||||
"attachments": [{
|
||||
"contentType": "application/vnd.microsoft.card.adaptive",
|
||||
"contentUrl": null,
|
||||
"content": { ...adaptive card JSON... }
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
To get a webhook URL in Teams: go to the target channel → **...** → Connectors →
|
||||
**Incoming Webhook** → Configure → copy URL → paste into Pulse admin.
|
||||
|
||||
---
|
||||
|
||||
## 7. Technical Considerations
|
||||
|
||||
### Existing Infrastructure to Reuse
|
||||
|
||||
- **`ZabbixClient`** (`lib/services/zabbix-client.ts`) — add `getOpenProblems()` and
|
||||
`getResolvedEvents(from: Date, to: Date)` methods.
|
||||
- **`MsGraphClient`** — no changes needed for v1.
|
||||
- **`SyncScheduler`** — add `'morning-summary'` sync type; register in `initialize()`.
|
||||
- **Postgres** — three new tables: `morning_summaries`, `morning_summary_webhooks`,
|
||||
`morning_summary_config`.
|
||||
|
||||
### New Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `lib/services/morning-summary-service.ts` | Aggregation, card building, webhook delivery |
|
||||
| `app/api/notifications/morning-summary/send/route.ts` | `POST` — send to all/selected webhooks |
|
||||
| `app/api/notifications/morning-summary/test/route.ts` | `POST` — test send to single webhook |
|
||||
| `app/api/notifications/morning-summary/webhooks/route.ts` | `GET`/`POST` — manage webhook list |
|
||||
| `app/api/notifications/morning-summary/webhooks/[id]/route.ts` | `PUT`/`DELETE` — update/remove webhook |
|
||||
| `app/api/notifications/morning-summary/config/route.ts` | `GET`/`PUT` — read/update settings |
|
||||
| `app/admin/morning-summary/page.tsx` | Admin UI: webhooks, schedule, config, preview |
|
||||
| `db/migrations/XXXX_morning_summary.sql` | All three new tables |
|
||||
|
||||
### Zabbix API Notes
|
||||
|
||||
- `problem.get` returns `acknowledged` as `"0"/"1"` strings and `clock` as Unix timestamp string.
|
||||
- Use `selectAcknowledges: 'extend'` — problem is unacknowledged if the array is empty.
|
||||
- `event.get` with `value: 0` returns recoveries; duration = `r_clock - clock` (both seconds).
|
||||
- Enrich problems with host/client by cross-referencing `objectid` (triggerid) against a
|
||||
trigger→host map built from `trigger.get` with `selectHosts: ['hostid', 'name']`, or
|
||||
directly via `problem.get` with `selectSuppressionData` + a pre-built hostid→client map.
|
||||
|
||||
### MS Graph Permissions
|
||||
|
||||
No Graph permissions are required for v1 delivery. Delivery uses Teams incoming webhook URLs
|
||||
(plain HTTPS POST — no auth needed beyond the secret embedded in the URL).
|
||||
|
||||
Existing granted permissions (`Chat.Create`, `Team.ReadBasic.All`, `Channel.ReadBasic.All`,
|
||||
`ChatMessage.Send`, `ChannelMessage.Send`) are unused by this feature but can remain for
|
||||
future use.
|
||||
|
||||
### Timezone
|
||||
|
||||
- Cron `30 6 * * 1-5` fires at 6:30 AM server time — ensure server is set to ET or adjust cron.
|
||||
- Report window computed in ET using `Intl.DateTimeFormat` or `date-fns-tz`.
|
||||
- Monday extended window: `startOfDay(subDays(monday, 3)) + 18h` = Friday 18:00 ET.
|
||||
|
||||
---
|
||||
|
||||
## 8. Success Metrics
|
||||
|
||||
- All configured recipients receive the DM at 6:30 AM on weekdays without manual action.
|
||||
- Test send from the UI delivers to the selected individual within 30 seconds.
|
||||
- Escalation button (On Call or Outages) posts to the correct Teams channel within 10 seconds
|
||||
and the user sees a confirmation page.
|
||||
- Monday card correctly shows the extended weekend window label and data.
|
||||
- Dashboard widget reflects the most recent summary within 1 minute of generation.
|
||||
- Zero unhandled errors surfaced to end users; all failures logged and shown in the admin UI.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open Questions
|
||||
|
||||
1. ~~**MS Graph permissions**~~ — Not needed for v1 webhook delivery.
|
||||
2. ~~**Team/channel names**~~ — Confirmed: team = `Technical`, channels = `On-Call` and `Outages-Issues`.
|
||||
3. ~~**App membership in Technical team**~~ — Not needed; webhook URLs bypass Graph entirely.
|
||||
4. **Error alerting** — If the 6:30 AM scheduled job fails entirely, how should that be
|
||||
surfaced? (ntfy push, Pulse admin badge, or both?)
|
||||
5. **Severity filter default** — Spec defaults to Warning (2) and above. Should
|
||||
Information (1) problems be included? Confirm with Tom/Lorentz.
|
||||
6. **Webhook URLs** — Lorentz needs to create incoming webhooks in the `On-Call` and
|
||||
`Outages-Issues` channels (Teams channel → ... → Connectors → Incoming Webhook → Configure)
|
||||
and paste the URLs into the Pulse admin UI once the feature is deployed.
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue