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:
lorentz 2026-03-11 09:34:51 -04:00
parent 19605f82aa
commit c518eefdb2
61 changed files with 11236 additions and 237 deletions

View 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 MonFri · 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 MonFri)</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>
);
}

View file

@ -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>

View 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 });
}
}

View 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;
}
}

View 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 });
}
}

View 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() });
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View file

@ -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
`);

View 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: [] });
}
}

View 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 }
);
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View file

@ -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: {

View 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(),
});
}

View file

@ -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}
/>
<ComplianceDetailTable mismatches={compliance.mismatches} />
<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

File diff suppressed because it is too large Load diff

View 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>
);
}

View file

@ -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 {

View file

@ -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>
);
}

View 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>
);
}

View file

@ -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">

View 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}
/>
)}
</>
);
}

View 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.

View 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

View 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;
}

View file

@ -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

View 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;
}

View 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;
}
}

View 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;
}

View file

@ -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 MonFri',
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 {

View file

@ -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.

View 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
View 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;
}
}

View 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;
}

View 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;
}

View file

@ -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> {

View file

@ -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

View file

@ -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;

View file

@ -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,
};

View file

@ -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',
};

View 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;

View 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);

View 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;

View 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);

View 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;

View 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);

View 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);

View 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;

View 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
View file

@ -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",

View file

@ -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",

View 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 MonFri.
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:
- **TuesdayFriday 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 TueFri (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.