feat: Morning NOC Summary adaptive card for Teams
- Add MorningSummaryService with Zabbix aggregation and adaptive card builder - Add webhook delivery system with Teams incoming webhooks - Add admin UI at /admin/morning-summary for webhook/config management - Add API routes: /send, /test, /webhooks, /webhooks/[id], /config, /history - Register morning-summary cron job in SyncScheduler (Mon-Fri 6:30 AM) - Add outages_only filter (Unavailable triggers only) - Fix host resolution: use getTriggerEnabledHosts to exclude disabled hosts - Fix resolved events: event.get value:1 scoped to window with r_eventid filter - Remove emojis from fact rows and section headers in card - Remove Open Zabbix button (duplicate of View Problems) - Add migrations: morning_summary_config + morning_summaries tables - Add outages_only column to morning_summary_config
This commit is contained in:
parent
19605f82aa
commit
c518eefdb2
61 changed files with 11236 additions and 237 deletions
467
app/admin/morning-summary/page.tsx
Normal file
467
app/admin/morning-summary/page.tsx
Normal file
|
|
@ -0,0 +1,467 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Send, RefreshCw, Trash2, Plus, CheckCircle2, XCircle,
|
||||||
|
AlertTriangle, Clock, Loader2, ChevronDown, ChevronUp, ToggleLeft, ToggleRight,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface WebhookConfig {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
webhook_url: string;
|
||||||
|
enabled: boolean;
|
||||||
|
last_delivered_at: string | null;
|
||||||
|
last_status: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SummaryConfig {
|
||||||
|
weekend_suppression: boolean;
|
||||||
|
monday_extended_window: boolean;
|
||||||
|
severity_filter: number;
|
||||||
|
outages_only: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SummaryRow {
|
||||||
|
id: number;
|
||||||
|
generated_at: string;
|
||||||
|
open_count: number;
|
||||||
|
resolved_count: number;
|
||||||
|
mttr_minutes: number | null;
|
||||||
|
is_weekend_window: boolean;
|
||||||
|
delivery_status: Record<string, { success: boolean; httpStatus?: number; error?: string }>;
|
||||||
|
card_payload?: object;
|
||||||
|
window_from?: string;
|
||||||
|
window_to?: string;
|
||||||
|
clients_affected?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(d: string | null) {
|
||||||
|
if (!d) return 'Never';
|
||||||
|
const date = new Date(d);
|
||||||
|
const diff = Date.now() - date.getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return 'Just now';
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
if (hrs < 24) return `${hrs}h ago`;
|
||||||
|
return `${Math.floor(hrs / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string | null }) {
|
||||||
|
if (!status) return <span className="text-xs text-muted-foreground">Never sent</span>;
|
||||||
|
if (status === 'success') return (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||||
|
<CheckCircle2 className="h-3 w-3" /> Success
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-red-500">
|
||||||
|
<XCircle className="h-3 w-3" /> Failed
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MorningSummaryPage() {
|
||||||
|
const [webhooks, setWebhooks] = useState<WebhookConfig[]>([]);
|
||||||
|
const [config, setConfig] = useState<SummaryConfig | null>(null);
|
||||||
|
const [history, setHistory] = useState<SummaryRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [testingId, setTestingId] = useState<number | null>(null);
|
||||||
|
const [toast, setToast] = useState<{ msg: string; ok: boolean } | null>(null);
|
||||||
|
const [cardExpanded, setCardExpanded] = useState(false);
|
||||||
|
const [showAddWebhook, setShowAddWebhook] = useState(false);
|
||||||
|
const [newLabel, setNewLabel] = useState('');
|
||||||
|
const [newUrl, setNewUrl] = useState('');
|
||||||
|
const [addingWebhook, setAddingWebhook] = useState(false);
|
||||||
|
|
||||||
|
const showToast = (msg: string, ok: boolean) => {
|
||||||
|
setToast({ msg, ok });
|
||||||
|
setTimeout(() => setToast(null), 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [wRes, cRes, hRes] = await Promise.all([
|
||||||
|
fetch('/api/notifications/morning-summary/webhooks'),
|
||||||
|
fetch('/api/notifications/morning-summary/config'),
|
||||||
|
fetch('/api/notifications/morning-summary/history'),
|
||||||
|
]);
|
||||||
|
const [wData, cData, hData] = await Promise.all([wRes.json(), cRes.json(), hRes.json()]);
|
||||||
|
setWebhooks(wData.webhooks ?? []);
|
||||||
|
setConfig(cData.config ?? null);
|
||||||
|
setHistory(hData.history ?? []);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchAll(); }, [fetchAll]);
|
||||||
|
|
||||||
|
const handleSendAll = async () => {
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/notifications/morning-summary/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error);
|
||||||
|
const ok = data.results?.filter((r: any) => r.success).length ?? 0;
|
||||||
|
const fail = data.results?.filter((r: any) => !r.success).length ?? 0;
|
||||||
|
showToast(`Sent — ${ok} succeeded, ${fail} failed`, fail === 0);
|
||||||
|
fetchAll();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(String(e), false);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async (webhookId: number, label: string) => {
|
||||||
|
setTestingId(webhookId);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/notifications/morning-summary/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ webhookId }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
showToast(data.result?.success ? `✅ Test sent to ${label}` : `❌ Test failed: ${data.result?.error ?? data.error}`, data.result?.success);
|
||||||
|
fetchAll();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(String(e), false);
|
||||||
|
} finally {
|
||||||
|
setTestingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleWebhook = async (webhook: WebhookConfig) => {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/notifications/morning-summary/webhooks/${webhook.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled: !webhook.enabled }),
|
||||||
|
});
|
||||||
|
setWebhooks(prev => prev.map(w => w.id === webhook.id ? { ...w, enabled: !w.enabled } : w));
|
||||||
|
} catch (e) {
|
||||||
|
showToast(String(e), false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteWebhook = async (id: number) => {
|
||||||
|
if (!confirm('Delete this webhook?')) return;
|
||||||
|
try {
|
||||||
|
await fetch(`/api/notifications/morning-summary/webhooks/${id}`, { method: 'DELETE' });
|
||||||
|
setWebhooks(prev => prev.filter(w => w.id !== id));
|
||||||
|
} catch (e) {
|
||||||
|
showToast(String(e), false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddWebhook = async () => {
|
||||||
|
if (!newLabel.trim() || !newUrl.trim()) return;
|
||||||
|
setAddingWebhook(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/notifications/morning-summary/webhooks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ label: newLabel.trim(), webhook_url: newUrl.trim() }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error);
|
||||||
|
setWebhooks(prev => [...prev, data.webhook]);
|
||||||
|
setNewLabel('');
|
||||||
|
setNewUrl('');
|
||||||
|
setShowAddWebhook(false);
|
||||||
|
showToast('Webhook added', true);
|
||||||
|
} catch (e) {
|
||||||
|
showToast(String(e), false);
|
||||||
|
} finally {
|
||||||
|
setAddingWebhook(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfigToggle = async (field: keyof SummaryConfig) => {
|
||||||
|
if (!config) return;
|
||||||
|
const updated = { ...config, [field]: !config[field] };
|
||||||
|
setConfig(updated);
|
||||||
|
try {
|
||||||
|
await fetch('/api/notifications/morning-summary/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ [field]: updated[field] }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
showToast(String(e), false);
|
||||||
|
setConfig(config);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const latestSummary = history[0] ?? null;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-6 space-y-8">
|
||||||
|
{/* Toast */}
|
||||||
|
{toast && (
|
||||||
|
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-sm font-medium flex items-center gap-2 ${toast.ok ? 'bg-green-500/10 border border-green-500/30 text-green-400' : 'bg-red-500/10 border border-red-500/30 text-red-400'}`}>
|
||||||
|
{toast.ok ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
|
||||||
|
{toast.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">☀️ Morning NOC Summary</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Scheduled 6:30 AM Mon–Fri · Posts to Teams channels via webhook</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchAll}><RefreshCw className="h-4 w-4 mr-1" /> Refresh</Button>
|
||||||
|
<Button size="sm" onClick={handleSendAll} disabled={sending}>
|
||||||
|
{sending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Send className="h-4 w-4 mr-1" />}
|
||||||
|
Send Now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last Run Stats */}
|
||||||
|
{latestSummary && (
|
||||||
|
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Last Run</h2>
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" /> {fmtDate(latestSummary.generated_at)}
|
||||||
|
{latestSummary.is_weekend_window && <span className="ml-2 px-1.5 py-0.5 bg-blue-500/10 text-blue-400 rounded text-xs">Weekend</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className={`text-2xl font-bold ${latestSummary.open_count > 0 ? 'text-red-400' : 'text-muted-foreground'}`}>{latestSummary.open_count}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Open</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className={`text-2xl font-bold ${latestSummary.resolved_count > 0 ? 'text-green-400' : 'text-muted-foreground'}`}>{latestSummary.resolved_count}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Resolved</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold">{latestSummary.mttr_minutes != null ? `${latestSummary.mttr_minutes}m` : '—'}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Avg MTTR</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Delivery results */}
|
||||||
|
{Object.keys(latestSummary.delivery_status).length > 0 && (
|
||||||
|
<div className="pt-2 border-t space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground font-medium">Delivery</p>
|
||||||
|
{Object.entries(latestSummary.delivery_status).map(([wid, r]) => {
|
||||||
|
const webhook = webhooks.find(w => w.id === parseInt(wid));
|
||||||
|
return (
|
||||||
|
<div key={wid} className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">{webhook?.label ?? `Webhook #${wid}`}</span>
|
||||||
|
{r.success
|
||||||
|
? <span className="text-green-500 flex items-center gap-1"><CheckCircle2 className="h-3 w-3" /> Delivered</span>
|
||||||
|
: <span className="text-red-500 flex items-center gap-1"><XCircle className="h-3 w-3" /> {r.error ?? `HTTP ${r.httpStatus}`}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Card preview toggle */}
|
||||||
|
{latestSummary.card_payload && (
|
||||||
|
<div className="pt-2 border-t">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={() => setCardExpanded(v => !v)}
|
||||||
|
>
|
||||||
|
{cardExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||||
|
{cardExpanded ? 'Hide' : 'Show'} card payload
|
||||||
|
</button>
|
||||||
|
{cardExpanded && (
|
||||||
|
<pre className="mt-2 text-xs bg-muted/30 rounded p-3 overflow-auto max-h-64 text-muted-foreground">
|
||||||
|
{JSON.stringify(latestSummary.card_payload, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Webhooks */}
|
||||||
|
<div className="rounded-lg border bg-card p-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Webhooks</h2>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setShowAddWebhook(v => !v)}>
|
||||||
|
<Plus className="h-3 w-3 mr-1" /> Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAddWebhook && (
|
||||||
|
<div className="rounded-md border border-dashed p-3 space-y-2 bg-muted/10">
|
||||||
|
<input
|
||||||
|
className="w-full text-sm bg-background border rounded px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
placeholder="Label (e.g. Technical / On-Call)"
|
||||||
|
value={newLabel}
|
||||||
|
onChange={e => setNewLabel(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="w-full text-sm bg-background border rounded px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||||
|
placeholder="Webhook URL"
|
||||||
|
value={newUrl}
|
||||||
|
onChange={e => setNewUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" onClick={handleAddWebhook} disabled={addingWebhook || !newLabel || !newUrl}>
|
||||||
|
{addingWebhook ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Add Webhook'}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => { setShowAddWebhook(false); setNewLabel(''); setNewUrl(''); }}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{webhooks.length === 0 && !showAddWebhook && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">No webhooks configured. Add one above.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{webhooks.map(webhook => (
|
||||||
|
<div key={webhook.id} className={`flex items-center justify-between rounded-md border px-3 py-2.5 ${webhook.enabled ? 'bg-background' : 'bg-muted/20 opacity-60'}`}>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium truncate">{webhook.label}</span>
|
||||||
|
{webhook.enabled
|
||||||
|
? <span className="text-xs px-1.5 py-0.5 bg-green-500/10 text-green-400 rounded">Enabled</span>
|
||||||
|
: <span className="text-xs px-1.5 py-0.5 bg-muted/40 text-muted-foreground rounded">Disabled</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-0.5">
|
||||||
|
<StatusBadge status={webhook.last_status} />
|
||||||
|
{webhook.last_delivered_at && (
|
||||||
|
<span className="text-xs text-muted-foreground">{fmtDate(webhook.last_delivered_at)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 ml-2">
|
||||||
|
<Button
|
||||||
|
size="sm" variant="ghost"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
disabled={testingId === webhook.id}
|
||||||
|
onClick={() => handleTest(webhook.id, webhook.label)}
|
||||||
|
>
|
||||||
|
{testingId === webhook.id ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Test'}
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
className="p-1.5 rounded hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => handleToggleWebhook(webhook)}
|
||||||
|
title={webhook.enabled ? 'Disable' : 'Enable'}
|
||||||
|
>
|
||||||
|
{webhook.enabled
|
||||||
|
? <ToggleRight className="h-4 w-4 text-green-500" />
|
||||||
|
: <ToggleLeft className="h-4 w-4" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-1.5 rounded hover:bg-red-500/10 transition-colors text-muted-foreground hover:text-red-500"
|
||||||
|
onClick={() => handleDeleteWebhook(webhook.id)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Schedule Config */}
|
||||||
|
{config && (
|
||||||
|
<div className="rounded-lg border bg-card p-4 space-y-4">
|
||||||
|
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Schedule Settings</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Weekend Suppression</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Skip Saturday & Sunday (cron already limits to Mon–Fri)</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => handleConfigToggle('weekend_suppression')} className="text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
{config.weekend_suppression
|
||||||
|
? <ToggleRight className="h-6 w-6 text-green-500" />
|
||||||
|
: <ToggleLeft className="h-6 w-6" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Monday Extended Window</p>
|
||||||
|
<p className="text-xs text-muted-foreground">On Mondays, extend window to cover the full weekend (Fri 6 PM → Mon 6:30 AM)</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => handleConfigToggle('monday_extended_window')} className="text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
{config.monday_extended_window
|
||||||
|
? <ToggleRight className="h-6 w-6 text-green-500" />
|
||||||
|
: <ToggleLeft className="h-6 w-6" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Outages Only</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Only show "Unavailable" problems — filter out slow response and other non-outage alerts</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => handleConfigToggle('outages_only')} className="text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
{config.outages_only
|
||||||
|
? <ToggleRight className="h-6 w-6 text-green-500" />
|
||||||
|
: <ToggleLeft className="h-6 w-6" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Run History */}
|
||||||
|
{history.length > 0 && (
|
||||||
|
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||||
|
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Recent Runs</h2>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{history.map(row => {
|
||||||
|
const statusEntries = Object.values(row.delivery_status);
|
||||||
|
const allOk = statusEntries.length > 0 && statusEntries.every((r: any) => r.success);
|
||||||
|
const anyFail = statusEntries.some((r: any) => !r.success);
|
||||||
|
return (
|
||||||
|
<div key={row.id} className="flex items-center justify-between text-sm py-1.5 border-b last:border-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{allOk && <CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />}
|
||||||
|
{anyFail && <AlertTriangle className="h-3.5 w-3.5 text-yellow-500 shrink-0" />}
|
||||||
|
{statusEntries.length === 0 && <Clock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
|
||||||
|
<span className="text-muted-foreground">{fmtDate(row.generated_at)}</span>
|
||||||
|
{row.is_weekend_window && <span className="text-xs px-1.5 py-0.5 bg-blue-500/10 text-blue-400 rounded">Weekend</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className={row.open_count > 0 ? 'text-red-400' : 'text-muted-foreground'}>
|
||||||
|
{row.open_count} open
|
||||||
|
</span>
|
||||||
|
<span className={row.resolved_count > 0 ? 'text-green-400' : 'text-muted-foreground'}>
|
||||||
|
{row.resolved_count} resolved
|
||||||
|
</span>
|
||||||
|
{row.mttr_minutes != null && (
|
||||||
|
<span className="text-muted-foreground">{row.mttr_minutes}m MTTR</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -37,8 +37,13 @@ import {
|
||||||
Building2,
|
Building2,
|
||||||
Server,
|
Server,
|
||||||
GitFork,
|
GitFork,
|
||||||
|
Plus,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Network,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { HostManager } from '@/components/zabbix/host-manager';
|
||||||
|
|
||||||
type SyncMode = 'all' | 'client' | 'site';
|
type SyncMode = 'all' | 'client' | 'site';
|
||||||
|
|
||||||
|
|
@ -117,12 +122,37 @@ export default function ZabbixWanPage() {
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const tableBottomRef = useRef<HTMLDivElement>(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(() => {
|
useEffect(() => {
|
||||||
fetch('/api/rmm/site-mappings')
|
fetch('/api/rmm/site-mappings')
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => setMappings(d.mappings ?? []))
|
.then((d) => setMappings(d.mappings ?? []))
|
||||||
.catch(() => toast.error('Failed to load site mappings'))
|
.catch(() => toast.error('Failed to load site mappings'))
|
||||||
.finally(() => setLoadingMappings(false));
|
.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
|
// Scroll results table as rows stream in
|
||||||
|
|
@ -219,6 +249,42 @@ export default function ZabbixWanPage() {
|
||||||
setRunning(false);
|
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 (
|
return (
|
||||||
<div className="container mx-auto py-8 max-w-6xl space-y-6">
|
<div className="container mx-auto py-8 max-w-6xl space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -410,6 +476,142 @@ export default function ZabbixWanPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
||||||
{(results.length > 0 || running || fatalError) && (
|
{(results.length > 0 || running || fatalError) && (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
|
||||||
85
app/api/data/contracts/[id]/services/route.ts
Normal file
85
app/api/data/contracts/[id]/services/route.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
/**
|
||||||
|
* Contract Services Detail API
|
||||||
|
* GET /api/data/contracts/[id]/services - Returns a contract with all its service lines
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import postgresClient from '@/lib/services/postgres-client';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const contractId = parseInt(id);
|
||||||
|
if (isNaN(contractId)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid contract ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contractResult = await postgresClient.query(
|
||||||
|
`SELECT ct.*, c.company_name
|
||||||
|
FROM contracts ct
|
||||||
|
LEFT JOIN companies c ON c.id = ct.company_id
|
||||||
|
WHERE ct.id = $1 AND ct.is_deleted = false`,
|
||||||
|
[contractId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (contractResult.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Contract not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contract = contractResult.rows[0];
|
||||||
|
|
||||||
|
const servicesResult = await postgresClient.query(
|
||||||
|
`SELECT
|
||||||
|
cs.id,
|
||||||
|
cs.service_id,
|
||||||
|
cs.service_name,
|
||||||
|
cs.description,
|
||||||
|
cs.unit_price,
|
||||||
|
cs.unit_cost,
|
||||||
|
cs.quantity,
|
||||||
|
cs.adjusted_price,
|
||||||
|
cs.period_type,
|
||||||
|
cs.start_date,
|
||||||
|
cs.end_date,
|
||||||
|
s.name AS catalog_name
|
||||||
|
FROM contract_services cs
|
||||||
|
LEFT JOIN autotask_services s ON s.id = cs.service_id
|
||||||
|
WHERE cs.contract_id = $1 AND cs.is_deleted = false
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(cs.service_name, s.name) ILIKE '%workstation%backup%'
|
||||||
|
OR COALESCE(cs.service_name, s.name) ILIKE '%w/ backup%'
|
||||||
|
OR COALESCE(cs.service_name, s.name) ILIKE '%windows server%'
|
||||||
|
OR COALESCE(cs.service_name, s.name) ILIKE '%server virtual%'
|
||||||
|
OR COALESCE(cs.service_name, s.name) ILIKE '%server phys%'
|
||||||
|
OR COALESCE(cs.service_name, s.name) ILIKE '%esxi host%'
|
||||||
|
THEN 0
|
||||||
|
ELSE 1
|
||||||
|
END,
|
||||||
|
COALESCE(cs.service_name, s.name)`,
|
||||||
|
[contractId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const periodLabels: Record<number, string> = {
|
||||||
|
1: 'Monthly',
|
||||||
|
2: 'Quarterly',
|
||||||
|
3: 'Semi-Annual',
|
||||||
|
4: 'Annual',
|
||||||
|
5: 'One-Time',
|
||||||
|
};
|
||||||
|
|
||||||
|
const services = servicesResult.rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
display_name: row.service_name || row.catalog_name || `Service #${row.service_id}`,
|
||||||
|
period_label: row.period_type ? (periodLabels[row.period_type] ?? `Type ${row.period_type}`) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ contract, services });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[CONTRACT-SERVICES-API] Error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch contract services' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
186
app/api/engagement/backfill-meetings/route.ts
Normal file
186
app/api/engagement/backfill-meetings/route.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getMsgraphClient } from '@/lib/services/msgraph-factory';
|
||||||
|
import { postgresClient } from '@/lib/services/postgres-client';
|
||||||
|
|
||||||
|
let backfillInProgress = false;
|
||||||
|
let backfillStatus: {
|
||||||
|
running: boolean;
|
||||||
|
started: string | null;
|
||||||
|
processed: number;
|
||||||
|
total: number;
|
||||||
|
currentUser: string | null;
|
||||||
|
errors: number;
|
||||||
|
done: boolean;
|
||||||
|
log: string[];
|
||||||
|
} = { running: false, started: null, processed: 0, total: 0, currentUser: null, errors: 0, done: false, log: [] };
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json(backfillStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
if (backfillInProgress) {
|
||||||
|
return NextResponse.json({ error: 'Backfill already running' }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const monthsBack = Math.min(Number(body.monthsBack ?? 12), 24);
|
||||||
|
|
||||||
|
backfillInProgress = true;
|
||||||
|
backfillStatus = {
|
||||||
|
running: true,
|
||||||
|
started: new Date().toISOString(),
|
||||||
|
processed: 0,
|
||||||
|
total: 0,
|
||||||
|
currentUser: null,
|
||||||
|
errors: 0,
|
||||||
|
done: false,
|
||||||
|
log: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run async — don't await
|
||||||
|
runBackfill(monthsBack).finally(() => {
|
||||||
|
backfillInProgress = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ started: true, monthsBack });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBackfill(monthsBack: number) {
|
||||||
|
const log = (msg: string) => {
|
||||||
|
console.log(`[MEETING-BACKFILL] ${msg}`);
|
||||||
|
backfillStatus.log.push(msg);
|
||||||
|
if (backfillStatus.log.length > 200) backfillStatus.log.shift();
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = getMsgraphClient();
|
||||||
|
|
||||||
|
// Fetch internal domains for attendee classification
|
||||||
|
const orgDomains = await client.getOrganizationDomains();
|
||||||
|
const internalDomains = new Set(orgDomains);
|
||||||
|
log(`Internal domains: ${[...internalDomains].join(', ')}`);
|
||||||
|
|
||||||
|
// Build contact email index for client matching
|
||||||
|
const contactRows = await postgresClient.query(
|
||||||
|
`SELECT id, company_id, LOWER(email_address) as e1,
|
||||||
|
LOWER(email_address2) as e2, LOWER(email_address3) as e3
|
||||||
|
FROM contacts WHERE (is_deleted = false OR is_deleted IS NULL)`
|
||||||
|
);
|
||||||
|
const contactEmailIndex = new Map<string, { contactId: number; companyId: number | null }>();
|
||||||
|
for (const row of contactRows.rows) {
|
||||||
|
for (const e of [row.e1, row.e2, row.e3]) {
|
||||||
|
if (e && !contactEmailIndex.has(e)) {
|
||||||
|
contactEmailIndex.set(e, { contactId: row.id, companyId: row.company_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log(`Contact index: ${contactEmailIndex.size} emails`);
|
||||||
|
|
||||||
|
// Get all active graph users
|
||||||
|
const usersResult = await postgresClient.query(
|
||||||
|
`SELECT id, email, display_name FROM graph_users WHERE account_enabled = true ORDER BY display_name`
|
||||||
|
);
|
||||||
|
const users = usersResult.rows;
|
||||||
|
backfillStatus.total = users.length;
|
||||||
|
log(`Users to backfill: ${users.length}, going back ${monthsBack} months`);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
// Build date range: from (monthsBack months ago, start of month) to 91 days ago
|
||||||
|
// (avoid re-syncing data already covered by the regular 90-day sync)
|
||||||
|
const backfillEnd = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||||
|
const backfillStart = new Date(now.getFullYear(), now.getMonth() - monthsBack, 1);
|
||||||
|
log(`Date range: ${backfillStart.toISOString().slice(0, 10)} → ${backfillEnd.toISOString().slice(0, 10)}`);
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
backfillStatus.currentUser = user.display_name;
|
||||||
|
try {
|
||||||
|
const events = await client.getUserCalendarEvents(user.id, backfillStart, backfillEnd);
|
||||||
|
let inserted = 0;
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (!event.id) continue;
|
||||||
|
try {
|
||||||
|
const startTime = new Date(event.start.dateTime);
|
||||||
|
const endTime = new Date(event.end.dateTime);
|
||||||
|
const durationMinutes = Math.max(0, Math.round((endTime.getTime() - startTime.getTime()) / 60000));
|
||||||
|
const attendeeCount = event.attendees.length;
|
||||||
|
|
||||||
|
const externalAttendees = event.attendees.filter(a => {
|
||||||
|
const aEmail = (a.emailAddress?.address ?? '').toLowerCase();
|
||||||
|
if (aEmail === user.email.toLowerCase()) return false;
|
||||||
|
const domain = aEmail.split('@')[1];
|
||||||
|
return domain && !internalDomains.has(domain);
|
||||||
|
});
|
||||||
|
|
||||||
|
const meetingResult = await postgresClient.query(
|
||||||
|
`INSERT INTO teams_meetings
|
||||||
|
(graph_event_id, user_email, subject, start_time, end_time,
|
||||||
|
duration_minutes, is_online_meeting, attendee_count, synced_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
|
||||||
|
ON CONFLICT (user_email, graph_event_id) DO UPDATE SET
|
||||||
|
subject = EXCLUDED.subject,
|
||||||
|
start_time = EXCLUDED.start_time,
|
||||||
|
end_time = EXCLUDED.end_time,
|
||||||
|
duration_minutes = EXCLUDED.duration_minutes,
|
||||||
|
is_online_meeting = EXCLUDED.is_online_meeting,
|
||||||
|
attendee_count = EXCLUDED.attendee_count,
|
||||||
|
synced_at = NOW()
|
||||||
|
RETURNING id`,
|
||||||
|
[event.id, user.email, event.subject, startTime, endTime,
|
||||||
|
durationMinutes, event.isOnlineMeeting, attendeeCount]
|
||||||
|
);
|
||||||
|
const meetingId = meetingResult.rows[0]?.id;
|
||||||
|
if (!meetingId) continue;
|
||||||
|
|
||||||
|
await postgresClient.query(
|
||||||
|
`DELETE FROM teams_meeting_attendees WHERE meeting_id = $1`,
|
||||||
|
[meetingId]
|
||||||
|
);
|
||||||
|
|
||||||
|
let clientCount = 0;
|
||||||
|
for (const att of externalAttendees) {
|
||||||
|
const attEmail = (att.emailAddress?.address ?? '').toLowerCase();
|
||||||
|
const attName = att.emailAddress?.name ?? null;
|
||||||
|
const match = attEmail ? contactEmailIndex.get(attEmail) : undefined;
|
||||||
|
await postgresClient.query(
|
||||||
|
`INSERT INTO teams_meeting_attendees
|
||||||
|
(meeting_id, attendee_email, attendee_name, matched_contact_id, matched_company_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[meetingId, attEmail || null, attName,
|
||||||
|
match?.contactId ?? null, match?.companyId ?? null]
|
||||||
|
);
|
||||||
|
if (match?.companyId) clientCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await postgresClient.query(
|
||||||
|
`UPDATE teams_meetings SET client_attendee_count = $1, has_client_attendees = $2 WHERE id = $3`,
|
||||||
|
[clientCount, clientCount > 0, meetingId]
|
||||||
|
);
|
||||||
|
inserted++;
|
||||||
|
} catch (evErr) {
|
||||||
|
const msg = evErr instanceof Error ? evErr.message : String(evErr);
|
||||||
|
log(` Event error ${event.id}: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`${user.display_name}: ${events.length} events, ${inserted} upserted`);
|
||||||
|
} catch (userErr) {
|
||||||
|
const msg = userErr instanceof Error ? userErr.message : String(userErr);
|
||||||
|
log(`${user.display_name}: SKIP — ${msg}`);
|
||||||
|
backfillStatus.errors++;
|
||||||
|
}
|
||||||
|
|
||||||
|
backfillStatus.processed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Done. ${backfillStatus.processed} users, ${backfillStatus.errors} errors.`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
log(`FATAL: ${msg}`);
|
||||||
|
} finally {
|
||||||
|
backfillStatus.running = false;
|
||||||
|
backfillStatus.done = true;
|
||||||
|
backfillStatus.currentUser = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
151
app/api/engagement/summary/route.ts
Normal file
151
app/api/engagement/summary/route.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { postgresClient } from '@/lib/services/postgres-client';
|
||||||
|
import { isMsgraphConfigured } from '@/lib/services/msgraph-factory';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const period = searchParams.get('period') || 'D30';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get latest snapshot date for this period
|
||||||
|
const latestResult = await postgresClient.query(
|
||||||
|
`SELECT MAX(period_end) as latest_date, MAX(synced_at) as synced_at
|
||||||
|
FROM engagement_snapshots WHERE period_type = $1`,
|
||||||
|
[period]
|
||||||
|
);
|
||||||
|
|
||||||
|
const latestDate = latestResult.rows[0]?.latest_date;
|
||||||
|
const lastSynced = latestResult.rows[0]?.synced_at;
|
||||||
|
|
||||||
|
if (!latestDate) {
|
||||||
|
return NextResponse.json({
|
||||||
|
totalStaff: 0,
|
||||||
|
activeThisPeriod: 0,
|
||||||
|
avgHoursWorked: 0,
|
||||||
|
avgBillableHours: 0,
|
||||||
|
avgTeamsMeetings: 0,
|
||||||
|
avgEmailsSent: 0,
|
||||||
|
lastSynced: null,
|
||||||
|
configured: isMsgraphConfigured(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interval map
|
||||||
|
const intervalMap: Record<string, string> = {
|
||||||
|
D7: '7 days',
|
||||||
|
D30: '30 days',
|
||||||
|
D90: '90 days',
|
||||||
|
};
|
||||||
|
const interval = intervalMap[period] || '30 days';
|
||||||
|
|
||||||
|
// Exclude service/automation accounts: those with a snapshot showing zero inbound
|
||||||
|
// across all channels (pure outbound senders like Autotask relay accounts)
|
||||||
|
const notAutomatedFilter = `NOT (
|
||||||
|
es.user_email IS NOT NULL
|
||||||
|
AND COALESCE(es.emails_received, 0) = 0
|
||||||
|
AND COALESCE(es.teams_chat_messages, 0) = 0
|
||||||
|
AND COALESCE(es.teams_meetings_attended, 0) = 0
|
||||||
|
AND COALESCE(es.teams_calls, 0) = 0
|
||||||
|
)`;
|
||||||
|
|
||||||
|
// Staff count: human accounts (exclude pure-outbound service accounts)
|
||||||
|
const staffResult = await postgresClient.query(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM graph_users gu
|
||||||
|
JOIN (
|
||||||
|
SELECT DISTINCT ON (LOWER(email)) id, email
|
||||||
|
FROM resources
|
||||||
|
WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL
|
||||||
|
ORDER BY LOWER(email), id
|
||||||
|
) r ON LOWER(r.email) = LOWER(gu.email)
|
||||||
|
LEFT JOIN engagement_snapshots es
|
||||||
|
ON LOWER(es.user_email) = LOWER(gu.email)
|
||||||
|
AND es.period_type = $1 AND es.period_end = $2
|
||||||
|
WHERE gu.account_enabled = true
|
||||||
|
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
|
||||||
|
AND LOWER(gu.email) NOT LIKE '%#ext#%'
|
||||||
|
AND ${notAutomatedFilter}`,
|
||||||
|
[period, latestDate]
|
||||||
|
);
|
||||||
|
const totalStaff = parseInt(staffResult.rows[0]?.count ?? '0');
|
||||||
|
|
||||||
|
// Active users (had any Teams or email activity)
|
||||||
|
const activeResult = await postgresClient.query(
|
||||||
|
`SELECT COUNT(DISTINCT es.user_email) as count
|
||||||
|
FROM engagement_snapshots es
|
||||||
|
JOIN graph_users gu ON LOWER(gu.email) = LOWER(es.user_email)
|
||||||
|
JOIN (
|
||||||
|
SELECT DISTINCT ON (LOWER(email)) id, email
|
||||||
|
FROM resources
|
||||||
|
WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL
|
||||||
|
ORDER BY LOWER(email), id
|
||||||
|
) r ON LOWER(r.email) = LOWER(gu.email)
|
||||||
|
WHERE es.period_type = $1 AND es.period_end = $2
|
||||||
|
AND (es.teams_meetings_attended > 0 OR es.teams_chat_messages > 0 OR es.emails_sent > 0)
|
||||||
|
AND gu.account_enabled = true
|
||||||
|
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
|
||||||
|
AND LOWER(gu.email) NOT LIKE '%#ext#%'
|
||||||
|
AND ${notAutomatedFilter}`,
|
||||||
|
[period, latestDate]
|
||||||
|
);
|
||||||
|
const activeThisPeriod = parseInt(activeResult.rows[0]?.count ?? '0');
|
||||||
|
|
||||||
|
// Avg Teams meetings and emails (excluding automated senders)
|
||||||
|
const avgResult = await postgresClient.query(
|
||||||
|
`SELECT
|
||||||
|
AVG(es.teams_meetings_attended) as avg_meetings,
|
||||||
|
AVG(es.emails_sent) as avg_emails_sent
|
||||||
|
FROM engagement_snapshots es
|
||||||
|
JOIN graph_users gu ON LOWER(gu.email) = LOWER(es.user_email)
|
||||||
|
JOIN (
|
||||||
|
SELECT DISTINCT ON (LOWER(email)) id, email
|
||||||
|
FROM resources
|
||||||
|
WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL
|
||||||
|
ORDER BY LOWER(email), id
|
||||||
|
) r ON LOWER(r.email) = LOWER(gu.email)
|
||||||
|
WHERE es.period_type = $1 AND es.period_end = $2
|
||||||
|
AND gu.account_enabled = true
|
||||||
|
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
|
||||||
|
AND LOWER(gu.email) NOT LIKE '%#ext#%'
|
||||||
|
AND ${notAutomatedFilter}`,
|
||||||
|
[period, latestDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Avg hours from Autotask time entries joined via resources
|
||||||
|
const hoursResult = await postgresClient.query(
|
||||||
|
`SELECT
|
||||||
|
AVG(resource_hours.total_hours) as avg_hours,
|
||||||
|
AVG(resource_hours.billable_hours) as avg_billable
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
r.email,
|
||||||
|
COALESCE(SUM(te.hours_worked), 0) as total_hours,
|
||||||
|
COALESCE(SUM(CASE WHEN COALESCE(te.billable, true) = true THEN te.hours_worked ELSE 0 END), 0) as billable_hours
|
||||||
|
FROM graph_users gu
|
||||||
|
JOIN resources r ON LOWER(r.email) = LOWER(gu.email)
|
||||||
|
AND (r.is_deleted = false OR r.is_deleted IS NULL)
|
||||||
|
LEFT JOIN time_entries te ON te.resource_id = r.id
|
||||||
|
AND te.entry_date >= NOW() - INTERVAL '${interval}'
|
||||||
|
AND (te.is_deleted = false OR te.is_deleted IS NULL)
|
||||||
|
WHERE gu.account_enabled = true
|
||||||
|
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
|
||||||
|
AND LOWER(gu.email) NOT LIKE '%#ext#%'
|
||||||
|
GROUP BY r.email
|
||||||
|
) resource_hours`
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
totalStaff,
|
||||||
|
activeThisPeriod,
|
||||||
|
avgHoursWorked: parseFloat(hoursResult.rows[0]?.avg_hours ?? '0').toFixed(1),
|
||||||
|
avgBillableHours: parseFloat(hoursResult.rows[0]?.avg_billable ?? '0').toFixed(1),
|
||||||
|
avgTeamsMeetings: parseFloat(avgResult.rows[0]?.avg_meetings ?? '0').toFixed(1),
|
||||||
|
avgEmailsSent: parseFloat(avgResult.rows[0]?.avg_emails_sent ?? '0').toFixed(0),
|
||||||
|
lastSynced,
|
||||||
|
configured: isMsgraphConfigured(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ENGAGEMENT-SUMMARY] Error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch engagement summary' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/api/engagement/sync/route.ts
Normal file
30
app/api/engagement/sync/route.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getEngagementSyncService } from '@/lib/services/engagement-sync-service';
|
||||||
|
import { isMsgraphConfigured } from '@/lib/services/msgraph-factory';
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
if (!isMsgraphConfigured()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Microsoft Graph not configured. Set MSGRAPH_CLIENT_ID, MSGRAPH_CLIENT_SECRET, MSGRAPH_TENANT_ID.' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = getEngagementSyncService();
|
||||||
|
|
||||||
|
if (service.isSyncInProgress()) {
|
||||||
|
return NextResponse.json({ error: 'Sync already in progress' }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire and forget
|
||||||
|
service.sync().catch(err => {
|
||||||
|
console.error('[ENGAGEMENT-SYNC-API] Background sync failed:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Engagement sync started' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const service = getEngagementSyncService();
|
||||||
|
return NextResponse.json({ isSyncing: service.isSyncInProgress() });
|
||||||
|
}
|
||||||
227
app/api/engagement/user/[userId]/history/route.ts
Normal file
227
app/api/engagement/user/[userId]/history/route.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { postgresClient } from '@/lib/services/postgres-client';
|
||||||
|
import { isZoomConfigured } from '@/lib/services/zoom-factory';
|
||||||
|
|
||||||
|
interface MonthData {
|
||||||
|
month: string;
|
||||||
|
hoursWorked: number;
|
||||||
|
billableHours: number;
|
||||||
|
daysWorked: number;
|
||||||
|
teamsMessages: number;
|
||||||
|
teamsPrivateMessages: number;
|
||||||
|
teamsCalls: number;
|
||||||
|
meetingsAttended: number;
|
||||||
|
meetingsOrganized: number;
|
||||||
|
emailsSent: number;
|
||||||
|
emailsReceived: number;
|
||||||
|
totalMeetings: number;
|
||||||
|
clientMeetings: number;
|
||||||
|
meetingDurationMinutes: number;
|
||||||
|
zoomCalls: number;
|
||||||
|
zoomClientCalls: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ userId: string }> }
|
||||||
|
) {
|
||||||
|
const { userId } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userResult = await postgresClient.query(
|
||||||
|
`SELECT gu.*,
|
||||||
|
(SELECT r2.id FROM resources r2
|
||||||
|
WHERE LOWER(r2.email) = LOWER(gu.email)
|
||||||
|
AND (r2.is_deleted = false OR r2.is_deleted IS NULL)
|
||||||
|
ORDER BY (SELECT MAX(te.entry_date) FROM time_entries te WHERE te.resource_id = r2.id AND (te.is_deleted = false OR te.is_deleted IS NULL)) DESC NULLS LAST
|
||||||
|
LIMIT 1) as autotask_resource_id
|
||||||
|
FROM graph_users gu
|
||||||
|
WHERE gu.id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userResult.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userResult.rows[0];
|
||||||
|
|
||||||
|
// Daily time entries for the past 365 days
|
||||||
|
const dailyResult = user.autotask_resource_id
|
||||||
|
? await postgresClient.query(
|
||||||
|
`SELECT
|
||||||
|
TO_CHAR(entry_date, 'YYYY-MM-DD') as date,
|
||||||
|
SUM(hours_worked) as hours_worked,
|
||||||
|
SUM(CASE WHEN COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as billable_hours
|
||||||
|
FROM time_entries
|
||||||
|
WHERE resource_id = $1
|
||||||
|
AND (is_deleted = false OR is_deleted IS NULL)
|
||||||
|
AND entry_date >= NOW() - INTERVAL '365 days'
|
||||||
|
GROUP BY TO_CHAR(entry_date, 'YYYY-MM-DD')
|
||||||
|
ORDER BY date`,
|
||||||
|
[user.autotask_resource_id]
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Monthly time entries for the past 12 months
|
||||||
|
const monthlyHoursResult = user.autotask_resource_id
|
||||||
|
? await postgresClient.query(
|
||||||
|
`SELECT
|
||||||
|
TO_CHAR(DATE_TRUNC('month', entry_date), 'YYYY-MM') as month,
|
||||||
|
SUM(hours_worked) as hours_worked,
|
||||||
|
SUM(CASE WHEN COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as billable_hours,
|
||||||
|
COUNT(DISTINCT TO_CHAR(entry_date, 'YYYY-MM-DD')) as days_worked
|
||||||
|
FROM time_entries
|
||||||
|
WHERE resource_id = $1
|
||||||
|
AND (is_deleted = false OR is_deleted IS NULL)
|
||||||
|
AND entry_date >= DATE_TRUNC('month', NOW() - INTERVAL '11 months')
|
||||||
|
GROUP BY DATE_TRUNC('month', entry_date)
|
||||||
|
ORDER BY month`,
|
||||||
|
[user.autotask_resource_id]
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Monthly engagement snapshots — latest D30 per calendar month
|
||||||
|
const monthlySnapshotsResult = await postgresClient.query(
|
||||||
|
`SELECT DISTINCT ON (TO_CHAR(period_end, 'YYYY-MM'))
|
||||||
|
TO_CHAR(period_end, 'YYYY-MM') as month,
|
||||||
|
teams_chat_messages,
|
||||||
|
teams_private_messages,
|
||||||
|
teams_calls,
|
||||||
|
teams_meetings_attended,
|
||||||
|
teams_meetings_organized,
|
||||||
|
emails_sent,
|
||||||
|
emails_received
|
||||||
|
FROM engagement_snapshots
|
||||||
|
WHERE LOWER(user_email) = LOWER($1)
|
||||||
|
AND period_type = 'D30'
|
||||||
|
AND period_end >= NOW() - INTERVAL '13 months'
|
||||||
|
ORDER BY TO_CHAR(period_end, 'YYYY-MM'), period_end DESC`,
|
||||||
|
[user.email]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Monthly Teams meetings
|
||||||
|
let monthlyMeetingsResult = null;
|
||||||
|
try {
|
||||||
|
monthlyMeetingsResult = await postgresClient.query(
|
||||||
|
`SELECT
|
||||||
|
TO_CHAR(DATE_TRUNC('month', start_time), 'YYYY-MM') as month,
|
||||||
|
COUNT(*) as total_meetings,
|
||||||
|
SUM(CASE WHEN has_client_attendees THEN 1 ELSE 0 END) as client_meetings,
|
||||||
|
SUM(COALESCE(duration_minutes, 0)) as total_duration_minutes
|
||||||
|
FROM teams_meetings
|
||||||
|
WHERE LOWER(user_email) = LOWER($1)
|
||||||
|
AND start_time >= DATE_TRUNC('month', NOW() - INTERVAL '11 months')
|
||||||
|
GROUP BY DATE_TRUNC('month', start_time)
|
||||||
|
ORDER BY month`,
|
||||||
|
[user.email]
|
||||||
|
);
|
||||||
|
} catch { /* table may not exist */ }
|
||||||
|
|
||||||
|
// Monthly Zoom calls
|
||||||
|
let monthlyZoomResult = null;
|
||||||
|
if (isZoomConfigured()) {
|
||||||
|
try {
|
||||||
|
monthlyZoomResult = await postgresClient.query(
|
||||||
|
`SELECT
|
||||||
|
TO_CHAR(DATE_TRUNC('month', start_time), 'YYYY-MM') as month,
|
||||||
|
COUNT(*) as call_count,
|
||||||
|
SUM(CASE WHEN matched_company_id IS NOT NULL THEN 1 ELSE 0 END) as client_calls
|
||||||
|
FROM zoom_calls
|
||||||
|
WHERE LOWER(resource_email) = LOWER($1)
|
||||||
|
AND call_status = 'completed'
|
||||||
|
AND COALESCE(duration_seconds, 0) > 0
|
||||||
|
AND start_time >= DATE_TRUNC('month', NOW() - INTERVAL '11 months')
|
||||||
|
GROUP BY DATE_TRUNC('month', start_time)
|
||||||
|
ORDER BY month`,
|
||||||
|
[user.email]
|
||||||
|
);
|
||||||
|
} catch { /* table may not exist */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a complete 12-month map
|
||||||
|
const now = new Date();
|
||||||
|
const monthMap = new Map<string, MonthData>();
|
||||||
|
for (let i = 11; i >= 0; i--) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
|
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
monthMap.set(key, {
|
||||||
|
month: key,
|
||||||
|
hoursWorked: 0,
|
||||||
|
billableHours: 0,
|
||||||
|
daysWorked: 0,
|
||||||
|
teamsMessages: 0,
|
||||||
|
teamsPrivateMessages: 0,
|
||||||
|
teamsCalls: 0,
|
||||||
|
meetingsAttended: 0,
|
||||||
|
meetingsOrganized: 0,
|
||||||
|
emailsSent: 0,
|
||||||
|
emailsReceived: 0,
|
||||||
|
totalMeetings: 0,
|
||||||
|
clientMeetings: 0,
|
||||||
|
meetingDurationMinutes: 0,
|
||||||
|
zoomCalls: 0,
|
||||||
|
zoomClientCalls: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of monthlyHoursResult?.rows ?? []) {
|
||||||
|
const m = monthMap.get(row.month);
|
||||||
|
if (m) {
|
||||||
|
m.hoursWorked = parseFloat(row.hours_worked ?? 0);
|
||||||
|
m.billableHours = parseFloat(row.billable_hours ?? 0);
|
||||||
|
m.daysWorked = parseInt(row.days_worked ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of monthlySnapshotsResult.rows) {
|
||||||
|
const m = monthMap.get(row.month);
|
||||||
|
if (m) {
|
||||||
|
m.teamsMessages = parseInt(row.teams_chat_messages ?? 0);
|
||||||
|
m.teamsPrivateMessages = parseInt(row.teams_private_messages ?? 0);
|
||||||
|
m.teamsCalls = parseInt(row.teams_calls ?? 0);
|
||||||
|
m.meetingsAttended = parseInt(row.teams_meetings_attended ?? 0);
|
||||||
|
m.meetingsOrganized = parseInt(row.teams_meetings_organized ?? 0);
|
||||||
|
m.emailsSent = parseInt(row.emails_sent ?? 0);
|
||||||
|
m.emailsReceived = parseInt(row.emails_received ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of monthlyMeetingsResult?.rows ?? []) {
|
||||||
|
const m = monthMap.get(row.month);
|
||||||
|
if (m) {
|
||||||
|
m.totalMeetings = parseInt(row.total_meetings ?? 0);
|
||||||
|
m.clientMeetings = parseInt(row.client_meetings ?? 0);
|
||||||
|
m.meetingDurationMinutes = parseInt(row.total_duration_minutes ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of monthlyZoomResult?.rows ?? []) {
|
||||||
|
const m = monthMap.get(row.month);
|
||||||
|
if (m) {
|
||||||
|
m.zoomCalls = parseInt(row.call_count ?? 0);
|
||||||
|
m.zoomClientCalls = parseInt(row.client_calls ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
displayName: user.display_name,
|
||||||
|
email: user.email,
|
||||||
|
jobTitle: user.job_title,
|
||||||
|
department: user.department,
|
||||||
|
autotaskResourceId: user.autotask_resource_id,
|
||||||
|
},
|
||||||
|
daily: (dailyResult?.rows ?? []).map(r => ({
|
||||||
|
date: r.date,
|
||||||
|
hoursWorked: parseFloat(r.hours_worked ?? 0),
|
||||||
|
billableHours: parseFloat(r.billable_hours ?? 0),
|
||||||
|
})),
|
||||||
|
monthly: Array.from(monthMap.values()),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ENGAGEMENT-HISTORY] Error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch history' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
581
app/api/engagement/user/[userId]/route.ts
Normal file
581
app/api/engagement/user/[userId]/route.ts
Normal file
|
|
@ -0,0 +1,581 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { postgresClient } from '@/lib/services/postgres-client';
|
||||||
|
import { isZoomConfigured } from '@/lib/services/zoom-factory';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ userId: string }> }
|
||||||
|
) {
|
||||||
|
const { userId } = await params;
|
||||||
|
const periodParam = (request.nextUrl.searchParams.get('period') ?? 'D30').toUpperCase();
|
||||||
|
const periodDays = periodParam === 'D7' ? 7 : periodParam === 'D90' ? 90 : 30;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get user from graph_users
|
||||||
|
const userResult = await postgresClient.query(
|
||||||
|
`SELECT gu.*,
|
||||||
|
(SELECT r2.id FROM resources r2
|
||||||
|
WHERE LOWER(r2.email) = LOWER(gu.email)
|
||||||
|
AND (r2.is_deleted = false OR r2.is_deleted IS NULL)
|
||||||
|
ORDER BY (SELECT MAX(te.entry_date) FROM time_entries te WHERE te.resource_id = r2.id AND (te.is_deleted = false OR te.is_deleted IS NULL)) DESC NULLS LAST
|
||||||
|
LIMIT 1) as autotask_resource_id
|
||||||
|
FROM graph_users gu
|
||||||
|
WHERE gu.id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userResult.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userResult.rows[0];
|
||||||
|
|
||||||
|
// Get all snapshots for this user across periods
|
||||||
|
const snapshotsResult = await postgresClient.query(
|
||||||
|
`SELECT *
|
||||||
|
FROM engagement_snapshots
|
||||||
|
WHERE LOWER(user_email) = LOWER($1)
|
||||||
|
ORDER BY period_end DESC, period_type`,
|
||||||
|
[user.email]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get Autotask hours per period
|
||||||
|
const hoursResult = user.autotask_resource_id
|
||||||
|
? await postgresClient.query(
|
||||||
|
`SELECT
|
||||||
|
SUM(CASE WHEN entry_date >= NOW() - INTERVAL '7 days' THEN hours_worked ELSE 0 END) as hours_d7,
|
||||||
|
SUM(CASE WHEN entry_date >= NOW() - INTERVAL '30 days' THEN hours_worked ELSE 0 END) as hours_d30,
|
||||||
|
SUM(CASE WHEN entry_date >= NOW() - INTERVAL '90 days' THEN hours_worked ELSE 0 END) as hours_d90,
|
||||||
|
SUM(CASE WHEN entry_date >= NOW() - INTERVAL '7 days' AND COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as billable_d7,
|
||||||
|
SUM(CASE WHEN entry_date >= NOW() - INTERVAL '30 days' AND COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as billable_d30,
|
||||||
|
SUM(CASE WHEN entry_date >= NOW() - INTERVAL '90 days' AND COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as billable_d90
|
||||||
|
FROM time_entries
|
||||||
|
WHERE resource_id = $1
|
||||||
|
AND (is_deleted = false OR is_deleted IS NULL)
|
||||||
|
AND COALESCE(type, 0) NOT IN (15, 16)
|
||||||
|
AND COALESCE(allocation_code_id, 0) NOT IN (91206, 91209)`,
|
||||||
|
[user.autotask_resource_id]
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const hours = hoursResult?.rows[0];
|
||||||
|
|
||||||
|
// Recent time entries
|
||||||
|
const recentEntriesResult = user.autotask_resource_id
|
||||||
|
? await postgresClient.query(
|
||||||
|
`SELECT te.entry_date, te.hours_worked, te.billable, te.notes, te.title,
|
||||||
|
te.start_date_time, te.end_date_time,
|
||||||
|
COALESCE(c.company_name, tc.company_name) as company_name
|
||||||
|
FROM time_entries te
|
||||||
|
LEFT JOIN companies c ON c.id = te.company_id
|
||||||
|
LEFT JOIN tickets t ON t.id = te.ticket_id
|
||||||
|
LEFT JOIN companies tc ON tc.id = t.company_id
|
||||||
|
WHERE te.resource_id = $1
|
||||||
|
AND (te.is_deleted = false OR te.is_deleted IS NULL)
|
||||||
|
AND te.entry_date >= NOW() - ($2 || ' days')::INTERVAL
|
||||||
|
AND COALESCE(te.type, 0) NOT IN (15, 16)
|
||||||
|
AND COALESCE(te.allocation_code_id, 0) NOT IN (91206, 91209)
|
||||||
|
ORDER BY te.entry_date DESC
|
||||||
|
LIMIT 500`,
|
||||||
|
[user.autotask_resource_id, periodDays]
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Teams meeting detail records (client-attended)
|
||||||
|
let recentTeamsMeetings: Array<{
|
||||||
|
subject: string | null;
|
||||||
|
startTime: string;
|
||||||
|
durationMinutes: number | null;
|
||||||
|
attendeeCount: number;
|
||||||
|
clientAttendeeCount: number;
|
||||||
|
hasClientAttendees: boolean;
|
||||||
|
clientCompanies: string[];
|
||||||
|
participantNames: string[];
|
||||||
|
}> = [];
|
||||||
|
try {
|
||||||
|
const teamsMeetingsResult = await postgresClient.query(
|
||||||
|
`SELECT tm.subject, tm.start_time, tm.duration_minutes,
|
||||||
|
tm.attendee_count, tm.client_attendee_count, tm.has_client_attendees,
|
||||||
|
ARRAY_REMOVE(ARRAY_AGG(DISTINCT co.company_name), NULL) AS client_companies,
|
||||||
|
ARRAY_REMOVE(ARRAY_AGG(DISTINCT COALESCE(tma.attendee_name, tma.attendee_email)), NULL) AS participant_names
|
||||||
|
FROM teams_meetings tm
|
||||||
|
LEFT JOIN teams_meeting_attendees tma ON tma.meeting_id = tm.id
|
||||||
|
LEFT JOIN companies co ON co.id = tma.matched_company_id
|
||||||
|
WHERE LOWER(tm.user_email) = LOWER($1)
|
||||||
|
AND tm.start_time >= NOW() - ($2 || ' days')::INTERVAL
|
||||||
|
GROUP BY tm.id
|
||||||
|
ORDER BY tm.start_time DESC
|
||||||
|
LIMIT 200`,
|
||||||
|
[user.email, periodDays]
|
||||||
|
);
|
||||||
|
recentTeamsMeetings = teamsMeetingsResult.rows.map(r => ({
|
||||||
|
subject: r.subject,
|
||||||
|
startTime: r.start_time,
|
||||||
|
durationMinutes: r.duration_minutes,
|
||||||
|
attendeeCount: r.attendee_count,
|
||||||
|
clientAttendeeCount: r.client_attendee_count,
|
||||||
|
hasClientAttendees: r.has_client_attendees,
|
||||||
|
clientCompanies: r.client_companies ?? [],
|
||||||
|
participantNames: r.participant_names ?? [],
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// teams_meetings table may not exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peer max benchmarks — highest value across all active employees for this period
|
||||||
|
const peerMaxResult = await postgresClient.query(
|
||||||
|
`SELECT
|
||||||
|
MAX(h.hours_total) as max_hours,
|
||||||
|
MAX(h.hours_billable) as max_billable_hours,
|
||||||
|
MAX(m.meeting_count) as max_meetings,
|
||||||
|
MAX(m.client_meetings)as max_client_meetings,
|
||||||
|
MAX(s.messages) as max_messages,
|
||||||
|
MAX(s.emails) as max_emails,
|
||||||
|
MAX(zc.calls) as max_calls
|
||||||
|
FROM (
|
||||||
|
SELECT resource_id,
|
||||||
|
SUM(hours_worked) as hours_total,
|
||||||
|
SUM(CASE WHEN COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as hours_billable
|
||||||
|
FROM time_entries
|
||||||
|
WHERE (is_deleted = false OR is_deleted IS NULL)
|
||||||
|
AND entry_date >= NOW() - ($1 || ' days')::INTERVAL
|
||||||
|
AND COALESCE(type, 0) NOT IN (15, 16)
|
||||||
|
AND COALESCE(allocation_code_id, 0) NOT IN (91206, 91209)
|
||||||
|
GROUP BY resource_id
|
||||||
|
) h
|
||||||
|
CROSS JOIN (
|
||||||
|
SELECT user_email,
|
||||||
|
COUNT(*) as meeting_count,
|
||||||
|
SUM(CASE WHEN has_client_attendees THEN 1 ELSE 0 END) as client_meetings
|
||||||
|
FROM teams_meetings
|
||||||
|
WHERE start_time >= NOW() - ($1 || ' days')::INTERVAL
|
||||||
|
GROUP BY user_email
|
||||||
|
) m
|
||||||
|
CROSS JOIN (
|
||||||
|
SELECT user_email,
|
||||||
|
MAX(teams_chat_messages + teams_private_messages) as messages,
|
||||||
|
MAX(emails_sent) as emails
|
||||||
|
FROM engagement_snapshots
|
||||||
|
WHERE period_type = $2
|
||||||
|
GROUP BY user_email
|
||||||
|
) s
|
||||||
|
CROSS JOIN (
|
||||||
|
SELECT resource_email,
|
||||||
|
COUNT(*) as calls
|
||||||
|
FROM zoom_calls
|
||||||
|
WHERE call_status = 'completed'
|
||||||
|
AND start_time >= NOW() - ($1 || ' days')::INTERVAL
|
||||||
|
GROUP BY resource_email
|
||||||
|
) zc`,
|
||||||
|
[periodDays, periodParam]
|
||||||
|
).catch(() => null);
|
||||||
|
|
||||||
|
// Previous period values for trend calculation
|
||||||
|
const prevPeriodResult = user.autotask_resource_id
|
||||||
|
? await postgresClient.query(
|
||||||
|
`SELECT
|
||||||
|
SUM(hours_worked) as prev_hours,
|
||||||
|
SUM(CASE WHEN COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as prev_billable
|
||||||
|
FROM time_entries
|
||||||
|
WHERE resource_id = $1
|
||||||
|
AND (is_deleted = false OR is_deleted IS NULL)
|
||||||
|
AND entry_date >= NOW() - ($2 || ' days')::INTERVAL * 2
|
||||||
|
AND entry_date < NOW() - ($2 || ' days')::INTERVAL
|
||||||
|
AND COALESCE(type, 0) NOT IN (15, 16)
|
||||||
|
AND COALESCE(allocation_code_id, 0) NOT IN (91206, 91209)`,
|
||||||
|
[user.autotask_resource_id, periodDays]
|
||||||
|
).catch(() => null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const prevMeetingsResult = await postgresClient.query(
|
||||||
|
`SELECT COUNT(*) as prev_meetings,
|
||||||
|
SUM(CASE WHEN has_client_attendees THEN 1 ELSE 0 END) as prev_client_meetings
|
||||||
|
FROM teams_meetings
|
||||||
|
WHERE LOWER(user_email) = LOWER($1)
|
||||||
|
AND start_time >= NOW() - ($2 || ' days')::INTERVAL * 2
|
||||||
|
AND start_time < NOW() - ($2 || ' days')::INTERVAL`,
|
||||||
|
[user.email, periodDays]
|
||||||
|
).catch(() => null);
|
||||||
|
|
||||||
|
const prevZoomResult = await postgresClient.query(
|
||||||
|
`SELECT COUNT(*) as prev_calls
|
||||||
|
FROM zoom_calls
|
||||||
|
WHERE LOWER(resource_email) = LOWER($1)
|
||||||
|
AND call_status = 'completed'
|
||||||
|
AND start_time >= NOW() - ($2 || ' days')::INTERVAL * 2
|
||||||
|
AND start_time < NOW() - ($2 || ' days')::INTERVAL`,
|
||||||
|
[user.email, periodDays]
|
||||||
|
).catch(() => null);
|
||||||
|
|
||||||
|
// After-hours meetings (5:30 PM – 7:00 AM America/New_York)
|
||||||
|
const afterHoursMeetingsResult = await postgresClient.query(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM teams_meetings
|
||||||
|
WHERE LOWER(user_email) = LOWER($1)
|
||||||
|
AND start_time >= NOW() - ($2 || ' days')::INTERVAL
|
||||||
|
AND (
|
||||||
|
EXTRACT(HOUR FROM start_time AT TIME ZONE 'America/New_York') * 60
|
||||||
|
+ EXTRACT(MINUTE FROM start_time AT TIME ZONE 'America/New_York') >= 1050
|
||||||
|
OR
|
||||||
|
EXTRACT(HOUR FROM start_time AT TIME ZONE 'America/New_York') * 60
|
||||||
|
+ EXTRACT(MINUTE FROM start_time AT TIME ZONE 'America/New_York') < 420
|
||||||
|
)`,
|
||||||
|
[user.email, periodDays]
|
||||||
|
).catch(() => ({ rows: [{ count: 0 }] }));
|
||||||
|
const afterHoursMeetings = parseInt(afterHoursMeetingsResult.rows[0]?.count ?? 0);
|
||||||
|
|
||||||
|
// Daily activity heatmap data
|
||||||
|
let dailyActivity: Array<{ date: string; meetings: number; zoomCalls: number; hours: number; meetingMins: number }> = [];
|
||||||
|
try {
|
||||||
|
const dailyResult = await postgresClient.query(
|
||||||
|
`SELECT
|
||||||
|
day::date as date,
|
||||||
|
COALESCE(SUM(meetings), 0)::int as meetings,
|
||||||
|
COALESCE(SUM(zoom_calls), 0)::int as zoom_calls,
|
||||||
|
COALESCE(SUM(hours), 0)::float as hours,
|
||||||
|
COALESCE(SUM(meeting_mins), 0)::int as meeting_mins
|
||||||
|
FROM (
|
||||||
|
SELECT DATE(start_time) as day, COUNT(*) as meetings, SUM(duration_minutes) as meeting_mins, 0 as zoom_calls, 0 as hours
|
||||||
|
FROM teams_meetings
|
||||||
|
WHERE LOWER(user_email) = LOWER($1)
|
||||||
|
AND start_time >= NOW() - ($2 || ' days')::INTERVAL
|
||||||
|
GROUP BY DATE(start_time)
|
||||||
|
UNION ALL
|
||||||
|
SELECT DATE(start_time) as day, 0, 0, COUNT(*) as zoom_calls, 0
|
||||||
|
FROM zoom_calls
|
||||||
|
WHERE LOWER(resource_email) = LOWER($1)
|
||||||
|
AND call_status = 'completed'
|
||||||
|
AND start_time >= NOW() - ($2 || ' days')::INTERVAL
|
||||||
|
GROUP BY DATE(start_time)
|
||||||
|
UNION ALL
|
||||||
|
SELECT DATE(entry_date) as day, 0, 0, 0, SUM(hours_worked) as hours
|
||||||
|
FROM time_entries te
|
||||||
|
WHERE te.resource_id = $3
|
||||||
|
AND (te.is_deleted = false OR te.is_deleted IS NULL)
|
||||||
|
AND entry_date >= NOW() - ($2 || ' days')::INTERVAL
|
||||||
|
AND COALESCE(te.type, 0) NOT IN (15, 16)
|
||||||
|
AND COALESCE(te.allocation_code_id, 0) NOT IN (91206, 91209)
|
||||||
|
GROUP BY DATE(entry_date)
|
||||||
|
) combined
|
||||||
|
GROUP BY day
|
||||||
|
ORDER BY day`,
|
||||||
|
[user.email, periodDays, user.autotask_resource_id]
|
||||||
|
);
|
||||||
|
dailyActivity = dailyResult.rows.map(r => ({
|
||||||
|
date: r.date instanceof Date ? r.date.toISOString().slice(0, 10) : String(r.date).slice(0, 10),
|
||||||
|
meetings: Number(r.meetings),
|
||||||
|
zoomCalls: Number(r.zoom_calls),
|
||||||
|
hours: Number(r.hours),
|
||||||
|
meetingMins: Number(r.meeting_mins),
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// ignore if tables missing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zoom data (only if configured and tables exist)
|
||||||
|
let zoomData = null;
|
||||||
|
if (isZoomConfigured()) {
|
||||||
|
try {
|
||||||
|
const email = user.email;
|
||||||
|
|
||||||
|
const [zoomCallsResult, zoomMeetingsResult, zoomTopClientsResult, recentCallsResult, recentMeetingsResult] = await Promise.all([
|
||||||
|
postgresClient.query(
|
||||||
|
`SELECT
|
||||||
|
SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' THEN 1 ELSE 0 END) as calls_d7,
|
||||||
|
SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' THEN 1 ELSE 0 END) as calls_d30,
|
||||||
|
SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' THEN 1 ELSE 0 END) as calls_d90,
|
||||||
|
SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' AND (matched_contact_id IS NOT NULL OR matched_company_id IS NOT NULL) THEN 1 ELSE 0 END) as client_calls_d7,
|
||||||
|
SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' AND (matched_contact_id IS NOT NULL OR matched_company_id IS NOT NULL) THEN 1 ELSE 0 END) as client_calls_d30,
|
||||||
|
SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' AND (matched_contact_id IS NOT NULL OR matched_company_id IS NOT NULL) THEN 1 ELSE 0 END) as client_calls_d90,
|
||||||
|
SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' AND direction = 'outbound' THEN 1 ELSE 0 END) as outbound_d7,
|
||||||
|
SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' AND direction = 'outbound' THEN 1 ELSE 0 END) as outbound_d30,
|
||||||
|
SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' AND direction = 'outbound' THEN 1 ELSE 0 END) as outbound_d90,
|
||||||
|
SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' AND direction = 'inbound' THEN 1 ELSE 0 END) as inbound_d7,
|
||||||
|
SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' AND direction = 'inbound' THEN 1 ELSE 0 END) as inbound_d30,
|
||||||
|
SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' AND direction = 'inbound' THEN 1 ELSE 0 END) as inbound_d90,
|
||||||
|
SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' THEN COALESCE(duration_seconds, 0) ELSE 0 END) as duration_d7,
|
||||||
|
SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' THEN COALESCE(duration_seconds, 0) ELSE 0 END) as duration_d30,
|
||||||
|
SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' THEN COALESCE(duration_seconds, 0) ELSE 0 END) as duration_d90
|
||||||
|
FROM zoom_calls
|
||||||
|
WHERE LOWER(resource_email) = LOWER($1)
|
||||||
|
AND call_status = 'completed'
|
||||||
|
AND COALESCE(duration_seconds, 0) > 0`,
|
||||||
|
[email]
|
||||||
|
),
|
||||||
|
postgresClient.query(
|
||||||
|
`SELECT
|
||||||
|
SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' THEN 1 ELSE 0 END) as meetings_d7,
|
||||||
|
SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' THEN 1 ELSE 0 END) as meetings_d30,
|
||||||
|
SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' THEN 1 ELSE 0 END) as meetings_d90,
|
||||||
|
SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' AND has_client_attendees = true THEN 1 ELSE 0 END) as client_meetings_d7,
|
||||||
|
SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' AND has_client_attendees = true THEN 1 ELSE 0 END) as client_meetings_d30,
|
||||||
|
SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' AND has_client_attendees = true THEN 1 ELSE 0 END) as client_meetings_d90
|
||||||
|
FROM zoom_meetings
|
||||||
|
WHERE LOWER(host_email) = LOWER($1)`,
|
||||||
|
[email]
|
||||||
|
),
|
||||||
|
postgresClient.query(
|
||||||
|
`SELECT
|
||||||
|
co.company_name,
|
||||||
|
COUNT(DISTINCT zc.id) as call_count,
|
||||||
|
COUNT(DISTINCT zm.id) as meeting_count
|
||||||
|
FROM companies co
|
||||||
|
LEFT JOIN zoom_calls zc
|
||||||
|
ON zc.matched_company_id = co.id
|
||||||
|
AND LOWER(zc.resource_email) = LOWER($1)
|
||||||
|
AND zc.start_time >= NOW() - INTERVAL '30 days'
|
||||||
|
LEFT JOIN zoom_meetings zm
|
||||||
|
ON zm.id IN (
|
||||||
|
SELECT zmp.meeting_id FROM zoom_meeting_participants zmp
|
||||||
|
WHERE zmp.matched_company_id = co.id
|
||||||
|
)
|
||||||
|
AND LOWER(zm.host_email) = LOWER($1)
|
||||||
|
AND zm.start_time >= NOW() - INTERVAL '30 days'
|
||||||
|
WHERE (zc.id IS NOT NULL OR zm.id IS NOT NULL)
|
||||||
|
GROUP BY co.id, co.company_name
|
||||||
|
ORDER BY (COUNT(DISTINCT zc.id) + COUNT(DISTINCT zm.id)) DESC
|
||||||
|
LIMIT 5`,
|
||||||
|
[email]
|
||||||
|
),
|
||||||
|
postgresClient.query(
|
||||||
|
`SELECT zc.direction, zc.call_status, zc.other_party_name, zc.other_party_number,
|
||||||
|
zc.start_time, zc.duration_seconds,
|
||||||
|
co.company_name
|
||||||
|
FROM zoom_calls zc
|
||||||
|
LEFT JOIN companies co ON co.id = zc.matched_company_id
|
||||||
|
WHERE LOWER(zc.resource_email) = LOWER($1)
|
||||||
|
AND zc.call_status = 'completed'
|
||||||
|
AND COALESCE(zc.duration_seconds, 0) > 0
|
||||||
|
ORDER BY zc.start_time DESC
|
||||||
|
LIMIT 30`,
|
||||||
|
[email]
|
||||||
|
),
|
||||||
|
postgresClient.query(
|
||||||
|
`SELECT zm.id, zm.topic, zm.start_time, zm.end_time, zm.duration_minutes,
|
||||||
|
zm.participant_count, zm.client_participant_count, zm.has_client_attendees,
|
||||||
|
ARRAY_REMOVE(ARRAY_AGG(DISTINCT CASE WHEN NOT zmp.is_internal AND zmp.participant_name IS NOT NULL THEN zmp.participant_name ELSE NULL END), NULL) AS external_participant_names,
|
||||||
|
ARRAY_REMOVE(ARRAY_AGG(DISTINCT co.company_name), NULL) AS client_companies
|
||||||
|
FROM zoom_meetings zm
|
||||||
|
LEFT JOIN zoom_meeting_participants zmp ON zmp.meeting_id = zm.id
|
||||||
|
LEFT JOIN companies co ON co.id = zmp.matched_company_id
|
||||||
|
WHERE (LOWER(zm.host_email) = LOWER($1)
|
||||||
|
OR EXISTS (SELECT 1 FROM zoom_meeting_participants p WHERE p.meeting_id = zm.id AND LOWER(p.participant_email) = LOWER($1)))
|
||||||
|
AND zm.start_time >= NOW() - ($2 || ' days')::INTERVAL
|
||||||
|
GROUP BY zm.id
|
||||||
|
ORDER BY zm.start_time DESC
|
||||||
|
LIMIT 100`,
|
||||||
|
[email, periodDays]
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cr = zoomCallsResult.rows[0];
|
||||||
|
const mr = zoomMeetingsResult.rows[0];
|
||||||
|
|
||||||
|
zoomData = {
|
||||||
|
calls: {
|
||||||
|
d7: {
|
||||||
|
total: parseInt(cr.calls_d7 ?? 0),
|
||||||
|
client: parseInt(cr.client_calls_d7 ?? 0),
|
||||||
|
outbound: parseInt(cr.outbound_d7 ?? 0),
|
||||||
|
inbound: parseInt(cr.inbound_d7 ?? 0),
|
||||||
|
durationSeconds: parseInt(cr.duration_d7 ?? 0),
|
||||||
|
},
|
||||||
|
d30: {
|
||||||
|
total: parseInt(cr.calls_d30 ?? 0),
|
||||||
|
client: parseInt(cr.client_calls_d30 ?? 0),
|
||||||
|
outbound: parseInt(cr.outbound_d30 ?? 0),
|
||||||
|
inbound: parseInt(cr.inbound_d30 ?? 0),
|
||||||
|
durationSeconds: parseInt(cr.duration_d30 ?? 0),
|
||||||
|
},
|
||||||
|
d90: {
|
||||||
|
total: parseInt(cr.calls_d90 ?? 0),
|
||||||
|
client: parseInt(cr.client_calls_d90 ?? 0),
|
||||||
|
outbound: parseInt(cr.outbound_d90 ?? 0),
|
||||||
|
inbound: parseInt(cr.inbound_d90 ?? 0),
|
||||||
|
durationSeconds: parseInt(cr.duration_d90 ?? 0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
meetings: {
|
||||||
|
d7: { total: parseInt(mr.meetings_d7 ?? 0), withClients: parseInt(mr.client_meetings_d7 ?? 0) },
|
||||||
|
d30: { total: parseInt(mr.meetings_d30 ?? 0), withClients: parseInt(mr.client_meetings_d30 ?? 0) },
|
||||||
|
d90: { total: parseInt(mr.meetings_d90 ?? 0), withClients: parseInt(mr.client_meetings_d90 ?? 0) },
|
||||||
|
},
|
||||||
|
topClients: zoomTopClientsResult.rows.map(r => ({
|
||||||
|
companyName: r.company_name,
|
||||||
|
callCount: parseInt(r.call_count),
|
||||||
|
meetingCount: parseInt(r.meeting_count),
|
||||||
|
})),
|
||||||
|
recentCalls: recentCallsResult.rows.map(r => ({
|
||||||
|
direction: r.direction,
|
||||||
|
status: r.call_status,
|
||||||
|
otherPartyName: r.other_party_name,
|
||||||
|
otherPartyNumber: r.other_party_number,
|
||||||
|
startTime: r.start_time,
|
||||||
|
durationSeconds: r.duration_seconds,
|
||||||
|
companyName: r.company_name,
|
||||||
|
})),
|
||||||
|
recentMeetings: recentMeetingsResult.rows.map(r => {
|
||||||
|
const zmStart = new Date(r.start_time).getTime();
|
||||||
|
const zmEnd = r.end_time
|
||||||
|
? new Date(r.end_time).getTime()
|
||||||
|
: r.duration_minutes
|
||||||
|
? zmStart + r.duration_minutes * 60_000
|
||||||
|
: zmStart + 60 * 60_000;
|
||||||
|
const zmClientNames: string[] = (r.client_companies ?? []).map((n: string) => n.toLowerCase());
|
||||||
|
const toMs = (ts: string | null): number | null => {
|
||||||
|
if (!ts) return null;
|
||||||
|
const s = ts.toString();
|
||||||
|
const normalized = /[Z+\-]\d*$/.test(s.trim()) ? s : s.trim() + 'Z';
|
||||||
|
return new Date(normalized).getTime();
|
||||||
|
};
|
||||||
|
const matched = (recentEntriesResult?.rows ?? []).filter(te => {
|
||||||
|
if (r.has_client_attendees && zmClientNames.length > 0) {
|
||||||
|
if (!te.company_name) return false;
|
||||||
|
const teCo = te.company_name.toLowerCase();
|
||||||
|
if (!zmClientNames.some((c: string) => teCo.includes(c) || c.includes(teCo))) return false;
|
||||||
|
}
|
||||||
|
if (te.start_date_time) {
|
||||||
|
const teStart = toMs(te.start_date_time)!;
|
||||||
|
const teEnd = te.end_date_time
|
||||||
|
? toMs(te.end_date_time)!
|
||||||
|
: teStart + te.hours_worked * 3_600_000;
|
||||||
|
const tolerance = 30 * 60_000;
|
||||||
|
return teStart < zmEnd + tolerance && teEnd > zmStart - tolerance;
|
||||||
|
}
|
||||||
|
const teDate = new Date((toMs(te.entry_date) ?? 0)).toUTCString().slice(0, 16);
|
||||||
|
const zmDate = new Date(r.start_time).toUTCString().slice(0, 16);
|
||||||
|
return teDate === zmDate;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
topic: r.topic,
|
||||||
|
startTime: r.start_time,
|
||||||
|
endTime: r.end_time,
|
||||||
|
durationMinutes: r.duration_minutes,
|
||||||
|
participantCount: r.participant_count,
|
||||||
|
clientParticipantCount: r.client_participant_count,
|
||||||
|
hasClientAttendees: r.has_client_attendees,
|
||||||
|
externalParticipantNames: r.external_participant_names ?? [],
|
||||||
|
clientCompanies: r.client_companies ?? [],
|
||||||
|
matchedEntries: matched.map(te => ({
|
||||||
|
hours_worked: te.hours_worked,
|
||||||
|
billable: te.billable,
|
||||||
|
notes: te.notes,
|
||||||
|
title: te.title,
|
||||||
|
company_name: te.company_name,
|
||||||
|
start_date_time: te.start_date_time,
|
||||||
|
end_date_time: te.end_date_time,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Zoom tables may not exist yet — return null
|
||||||
|
zoomData = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After-hours summary (messages from snapshot for current period, meetings from DB)
|
||||||
|
const currentSnap = snapshotsResult.rows.find(s => s.period_type === periodParam);
|
||||||
|
const totalMessages = (currentSnap?.teams_chat_messages ?? 0) + (currentSnap?.teams_private_messages ?? 0);
|
||||||
|
const totalMeetings = recentTeamsMeetings.length;
|
||||||
|
const afterHoursMessages = currentSnap?.after_hours_messages ?? 0;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
displayName: user.display_name,
|
||||||
|
email: user.email,
|
||||||
|
jobTitle: user.job_title,
|
||||||
|
department: user.department,
|
||||||
|
accountEnabled: user.account_enabled,
|
||||||
|
autotaskResourceId: user.autotask_resource_id,
|
||||||
|
},
|
||||||
|
afterHours: {
|
||||||
|
messages: afterHoursMessages,
|
||||||
|
meetings: afterHoursMeetings,
|
||||||
|
messagesPct: totalMessages > 0 ? Math.round((afterHoursMessages / totalMessages) * 100) : 0,
|
||||||
|
meetingsPct: totalMeetings > 0 ? Math.round((afterHoursMeetings / totalMeetings) * 100) : 0,
|
||||||
|
},
|
||||||
|
snapshots: snapshotsResult.rows,
|
||||||
|
hours: hours
|
||||||
|
? {
|
||||||
|
d7: { total: parseFloat(hours.hours_d7 ?? 0), billable: parseFloat(hours.billable_d7 ?? 0) },
|
||||||
|
d30: { total: parseFloat(hours.hours_d30 ?? 0), billable: parseFloat(hours.billable_d30 ?? 0) },
|
||||||
|
d90: { total: parseFloat(hours.hours_d90 ?? 0), billable: parseFloat(hours.billable_d90 ?? 0) },
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
recentEntries: recentEntriesResult?.rows ?? [],
|
||||||
|
recentTeamsMeetings: recentTeamsMeetings.map(mtg => {
|
||||||
|
const mtgStart = new Date(mtg.startTime).getTime();
|
||||||
|
const mtgEnd = mtg.durationMinutes
|
||||||
|
? mtgStart + mtg.durationMinutes * 60_000
|
||||||
|
: mtgStart + 60 * 60_000; // assume 1h if unknown
|
||||||
|
const mtgClientNames = mtg.clientCompanies.map((n: string) => n.toLowerCase());
|
||||||
|
// Normalize a DB timestamp to ms — treat naive timestamps as UTC
|
||||||
|
const toMs = (ts: string | null): number | null => {
|
||||||
|
if (!ts) return null;
|
||||||
|
const s = ts.toString();
|
||||||
|
// If no timezone info, append Z so Date parses it as UTC
|
||||||
|
const normalized = /[Z+\-]\d*$/.test(s.trim()) ? s : s.trim() + 'Z';
|
||||||
|
return new Date(normalized).getTime();
|
||||||
|
};
|
||||||
|
const matched = (recentEntriesResult?.rows ?? []).filter(te => {
|
||||||
|
// Company match: if the meeting has client companies, the time entry must be for one of them
|
||||||
|
if (mtg.hasClientAttendees && mtgClientNames.length > 0) {
|
||||||
|
if (!te.company_name) return false;
|
||||||
|
const teCo = te.company_name.toLowerCase();
|
||||||
|
if (!mtgClientNames.some((c: string) => teCo.includes(c) || c.includes(teCo))) return false;
|
||||||
|
}
|
||||||
|
// Time match
|
||||||
|
if (te.start_date_time) {
|
||||||
|
const teStart = toMs(te.start_date_time)!;
|
||||||
|
const teEnd = te.end_date_time
|
||||||
|
? toMs(te.end_date_time)!
|
||||||
|
: teStart + te.hours_worked * 3_600_000;
|
||||||
|
const tolerance = 30 * 60_000;
|
||||||
|
return teStart < mtgEnd + tolerance && teEnd > mtgStart - tolerance;
|
||||||
|
}
|
||||||
|
// Fallback: same UTC date
|
||||||
|
const teDate = new Date((toMs(te.entry_date) ?? 0)).toUTCString().slice(0, 16);
|
||||||
|
const mtgDate = new Date(mtg.startTime).toUTCString().slice(0, 16);
|
||||||
|
return teDate === mtgDate;
|
||||||
|
});
|
||||||
|
return { ...mtg, matchedEntries: matched.map(te => ({
|
||||||
|
hours_worked: te.hours_worked,
|
||||||
|
billable: te.billable,
|
||||||
|
notes: te.notes,
|
||||||
|
title: te.title,
|
||||||
|
company_name: te.company_name,
|
||||||
|
start_date_time: te.start_date_time,
|
||||||
|
end_date_time: te.end_date_time,
|
||||||
|
})) };
|
||||||
|
}),
|
||||||
|
meetingCounts: {
|
||||||
|
total: recentTeamsMeetings.length,
|
||||||
|
withClients: recentTeamsMeetings.filter(m => m.hasClientAttendees).length,
|
||||||
|
},
|
||||||
|
dailyActivity,
|
||||||
|
zoom: zoomData,
|
||||||
|
peerMax: peerMaxResult?.rows[0]
|
||||||
|
? {
|
||||||
|
hours: parseFloat(peerMaxResult.rows[0].max_hours ?? 0),
|
||||||
|
billableHours:parseFloat(peerMaxResult.rows[0].max_billable_hours ?? 0),
|
||||||
|
meetings: parseInt(peerMaxResult.rows[0].max_meetings ?? 0),
|
||||||
|
clientMeetings:parseInt(peerMaxResult.rows[0].max_client_meetings ?? 0),
|
||||||
|
messages: parseInt(peerMaxResult.rows[0].max_messages ?? 0),
|
||||||
|
emails: parseInt(peerMaxResult.rows[0].max_emails ?? 0),
|
||||||
|
calls: parseInt(peerMaxResult.rows[0].max_calls ?? 0),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
trend: {
|
||||||
|
hours: parseFloat(prevPeriodResult?.rows[0]?.prev_hours ?? 0),
|
||||||
|
billable: parseFloat(prevPeriodResult?.rows[0]?.prev_billable ?? 0),
|
||||||
|
meetings: parseInt(prevMeetingsResult?.rows[0]?.prev_meetings ?? 0),
|
||||||
|
calls: parseInt(prevZoomResult?.rows[0]?.prev_calls ?? 0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ENGAGEMENT-USER-DETAIL] Error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch user detail' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
191
app/api/engagement/users/route.ts
Normal file
191
app/api/engagement/users/route.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { postgresClient } from '@/lib/services/postgres-client';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const period = searchParams.get('period') || 'D30';
|
||||||
|
const sort = searchParams.get('sort') || 'billable_hours';
|
||||||
|
const order = searchParams.get('order') === 'asc' ? 'ASC' : 'DESC';
|
||||||
|
const page = Math.max(1, parseInt(searchParams.get('page') || '1'));
|
||||||
|
const pageSize = 50;
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const intervalMap: Record<string, string> = {
|
||||||
|
D7: '7 days',
|
||||||
|
D30: '30 days',
|
||||||
|
D90: '90 days',
|
||||||
|
};
|
||||||
|
const interval = intervalMap[period] || '30 days';
|
||||||
|
|
||||||
|
const allowedSorts: Record<string, string> = {
|
||||||
|
hours_worked: 'hours_worked',
|
||||||
|
billable_hours: 'billable_hours',
|
||||||
|
teams_meetings_attended: 'teams_meetings_attended',
|
||||||
|
teams_chat_messages: 'teams_chat_messages',
|
||||||
|
emails_sent: 'emails_sent',
|
||||||
|
last_activity: 'last_active',
|
||||||
|
display_name: 'display_name',
|
||||||
|
zoom_call_count: 'zoom_call_count',
|
||||||
|
zoom_meeting_count: 'zoom_meeting_count',
|
||||||
|
};
|
||||||
|
const sortCol = allowedSorts[sort] || 'billable_hours';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const latestResult = await postgresClient.query(
|
||||||
|
`SELECT MAX(period_end) as latest_date FROM engagement_snapshots WHERE period_type = $1`,
|
||||||
|
[period]
|
||||||
|
);
|
||||||
|
const latestDate = latestResult.rows[0]?.latest_date;
|
||||||
|
|
||||||
|
if (!latestDate) {
|
||||||
|
return NextResponse.json({ users: [], pagination: { total: 0, page, pageSize } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await postgresClient.query(
|
||||||
|
`SELECT COUNT(*) as total
|
||||||
|
FROM graph_users gu
|
||||||
|
JOIN (
|
||||||
|
SELECT DISTINCT ON (LOWER(email)) id, email
|
||||||
|
FROM resources
|
||||||
|
WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL
|
||||||
|
ORDER BY LOWER(email), id
|
||||||
|
) r ON LOWER(r.email) = LOWER(gu.email)
|
||||||
|
LEFT JOIN engagement_snapshots es_cnt
|
||||||
|
ON LOWER(es_cnt.user_email) = LOWER(gu.email)
|
||||||
|
AND es_cnt.period_type = $1 AND es_cnt.period_end = $2
|
||||||
|
WHERE gu.account_enabled = true
|
||||||
|
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
|
||||||
|
AND LOWER(gu.email) NOT LIKE '%#ext#%'
|
||||||
|
AND NOT (
|
||||||
|
es_cnt.user_email IS NOT NULL
|
||||||
|
AND COALESCE(es_cnt.emails_received, 0) = 0
|
||||||
|
AND COALESCE(es_cnt.teams_chat_messages, 0) = 0
|
||||||
|
AND COALESCE(es_cnt.teams_meetings_attended, 0) = 0
|
||||||
|
AND COALESCE(es_cnt.teams_calls, 0) = 0
|
||||||
|
)`,
|
||||||
|
[period, latestDate]
|
||||||
|
);
|
||||||
|
const total = parseInt(countResult.rows[0]?.total ?? '0');
|
||||||
|
|
||||||
|
const usersResult = await postgresClient.query(
|
||||||
|
`SELECT
|
||||||
|
gu.id as graph_user_id,
|
||||||
|
gu.display_name,
|
||||||
|
gu.email,
|
||||||
|
gu.job_title,
|
||||||
|
gu.department,
|
||||||
|
r.id as autotask_resource_id,
|
||||||
|
COALESCE(te_agg.total_hours, 0) as hours_worked,
|
||||||
|
COALESCE(te_agg.billable_hours, 0) as billable_hours,
|
||||||
|
COALESCE(es.teams_chat_messages, 0) as teams_chat_messages,
|
||||||
|
COALESCE(es.teams_private_messages, 0) as teams_private_messages,
|
||||||
|
COALESCE(es.teams_calls, 0) as teams_calls,
|
||||||
|
COALESCE(es.teams_meetings_attended, 0) as teams_meetings_attended,
|
||||||
|
COALESCE(es.teams_meetings_organized, 0) as teams_meetings_organized,
|
||||||
|
COALESCE(es.emails_sent, 0) as emails_sent,
|
||||||
|
COALESCE(es.emails_received, 0) as emails_received,
|
||||||
|
COALESCE(es.emails_read, 0) as emails_read,
|
||||||
|
COALESCE(es.audio_duration_seconds, 0) as audio_duration_seconds,
|
||||||
|
COALESCE(es.meeting_duration_seconds, 0) as meeting_duration_seconds,
|
||||||
|
COALESCE(es.meetings_with_external, 0) as meetings_with_external,
|
||||||
|
LEAST(GREATEST(es.last_activity_date, last_te.entry_date::date), CURRENT_DATE) as last_active,
|
||||||
|
COALESCE(zc.zoom_call_count, 0) as zoom_call_count,
|
||||||
|
COALESCE(zc.zoom_client_call_count, 0) as zoom_client_call_count,
|
||||||
|
COALESCE(zc.zoom_call_duration_seconds, 0) as zoom_call_duration_seconds,
|
||||||
|
COALESCE(zm.zoom_meeting_count, 0) as zoom_meeting_count,
|
||||||
|
COALESCE(zm.zoom_client_meeting_count, 0) as zoom_client_meeting_count
|
||||||
|
FROM graph_users gu
|
||||||
|
LEFT JOIN engagement_snapshots es
|
||||||
|
ON LOWER(es.user_email) = LOWER(gu.email)
|
||||||
|
AND es.period_type = $1
|
||||||
|
AND es.period_end = $2
|
||||||
|
JOIN (
|
||||||
|
SELECT DISTINCT ON (LOWER(email)) *
|
||||||
|
FROM resources
|
||||||
|
WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL
|
||||||
|
ORDER BY LOWER(email), id
|
||||||
|
) r ON LOWER(r.email) = LOWER(gu.email)
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(te.hours_worked), 0) as total_hours,
|
||||||
|
COALESCE(SUM(CASE WHEN COALESCE(te.billable, true) = true THEN te.hours_worked ELSE 0 END), 0) as billable_hours
|
||||||
|
FROM time_entries te
|
||||||
|
WHERE te.resource_id = r.id
|
||||||
|
AND te.entry_date >= NOW() - INTERVAL '${interval}'
|
||||||
|
AND (te.is_deleted = false OR te.is_deleted IS NULL)
|
||||||
|
) te_agg ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT MAX(te2.entry_date) as entry_date
|
||||||
|
FROM time_entries te2
|
||||||
|
WHERE te2.resource_id = r.id
|
||||||
|
AND (te2.is_deleted = false OR te2.is_deleted IS NULL)
|
||||||
|
) last_te ON true
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT resource_email,
|
||||||
|
COUNT(*) AS zoom_call_count,
|
||||||
|
COUNT(*) FILTER (WHERE matched_contact_id IS NOT NULL OR matched_company_id IS NOT NULL) AS zoom_client_call_count,
|
||||||
|
COALESCE(SUM(duration_seconds), 0) AS zoom_call_duration_seconds
|
||||||
|
FROM zoom_calls
|
||||||
|
WHERE start_time >= NOW() - INTERVAL '${interval}'
|
||||||
|
AND call_status = 'completed'
|
||||||
|
AND COALESCE(duration_seconds, 0) > 0
|
||||||
|
GROUP BY resource_email
|
||||||
|
) zc ON LOWER(r.email) = LOWER(zc.resource_email)
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT host_email,
|
||||||
|
COUNT(*) AS zoom_meeting_count,
|
||||||
|
COUNT(*) FILTER (WHERE has_client_attendees = true) AS zoom_client_meeting_count
|
||||||
|
FROM zoom_meetings
|
||||||
|
WHERE start_time >= NOW() - INTERVAL '${interval}'
|
||||||
|
GROUP BY host_email
|
||||||
|
) zm ON LOWER(r.email) = LOWER(zm.host_email)
|
||||||
|
WHERE gu.account_enabled = true
|
||||||
|
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
|
||||||
|
AND LOWER(gu.email) NOT LIKE '%#ext#%'
|
||||||
|
AND NOT (
|
||||||
|
es.user_email IS NOT NULL
|
||||||
|
AND COALESCE(es.emails_received, 0) = 0
|
||||||
|
AND COALESCE(es.teams_chat_messages, 0) = 0
|
||||||
|
AND COALESCE(es.teams_meetings_attended, 0) = 0
|
||||||
|
AND COALESCE(es.teams_calls, 0) = 0
|
||||||
|
)
|
||||||
|
ORDER BY ${sortCol} ${order} NULLS LAST
|
||||||
|
LIMIT $3 OFFSET $4`,
|
||||||
|
[period, latestDate, pageSize, offset]
|
||||||
|
);
|
||||||
|
|
||||||
|
const users = usersResult.rows.map(row => ({
|
||||||
|
graphUserId: row.graph_user_id,
|
||||||
|
displayName: row.display_name,
|
||||||
|
email: row.email,
|
||||||
|
jobTitle: row.job_title,
|
||||||
|
department: row.department,
|
||||||
|
autotaskResourceId: row.autotask_resource_id,
|
||||||
|
hoursWorked: parseFloat(row.hours_worked),
|
||||||
|
billableHours: parseFloat(row.billable_hours),
|
||||||
|
teamsMessages: parseInt(row.teams_chat_messages) + parseInt(row.teams_private_messages),
|
||||||
|
teamsCallCount: parseInt(row.teams_calls),
|
||||||
|
meetingsAttended: parseInt(row.teams_meetings_attended),
|
||||||
|
meetingsOrganized: parseInt(row.teams_meetings_organized),
|
||||||
|
emailsSent: parseInt(row.emails_sent),
|
||||||
|
emailsReceived: parseInt(row.emails_received),
|
||||||
|
audioDurationSeconds: parseInt(row.audio_duration_seconds),
|
||||||
|
meetingDurationSeconds: parseInt(row.meeting_duration_seconds),
|
||||||
|
meetingsWithExternal: parseInt(row.meetings_with_external),
|
||||||
|
lastActivity: row.last_active,
|
||||||
|
zoomCallCount: parseInt(row.zoom_call_count),
|
||||||
|
zoomClientCallCount: parseInt(row.zoom_client_call_count),
|
||||||
|
zoomCallDurationSeconds: parseInt(row.zoom_call_duration_seconds),
|
||||||
|
zoomMeetingCount: parseInt(row.zoom_meeting_count),
|
||||||
|
zoomClientMeetingCount: parseInt(row.zoom_client_meeting_count),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
users,
|
||||||
|
pagination: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ENGAGEMENT-USERS] Error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch engagement users' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/api/notifications/morning-summary/config/route.ts
Normal file
25
app/api/notifications/morning-summary/config/route.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getMorningSummaryService } from '@/lib/services/morning-summary-service';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const service = getMorningSummaryService();
|
||||||
|
const config = await service.getConfig();
|
||||||
|
return NextResponse.json({ config });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GET /api/notifications/morning-summary/config]', error);
|
||||||
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const service = getMorningSummaryService();
|
||||||
|
const config = await service.updateConfig(body);
|
||||||
|
return NextResponse.json({ config });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PUT /api/notifications/morning-summary/config]', error);
|
||||||
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/api/notifications/morning-summary/history/route.ts
Normal file
14
app/api/notifications/morning-summary/history/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getMorningSummaryService } from '@/lib/services/morning-summary-service';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const service = getMorningSummaryService();
|
||||||
|
const history = await service.getSummaryHistory(10);
|
||||||
|
const latest = await service.getLatestSummaryRow();
|
||||||
|
return NextResponse.json({ history, latest });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GET /api/notifications/morning-summary/history]', error);
|
||||||
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/api/notifications/morning-summary/send/route.ts
Normal file
23
app/api/notifications/morning-summary/send/route.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getMorningSummaryService } from '@/lib/services/morning-summary-service';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const webhookIds: number[] | undefined = body.webhookIds;
|
||||||
|
|
||||||
|
const service = getMorningSummaryService();
|
||||||
|
const { summary, results } = await service.run(webhookIds);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
openCount: summary.openCount,
|
||||||
|
resolvedCount: summary.resolvedCount,
|
||||||
|
isWeekendWindow: summary.isWeekendWindow,
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[POST /api/notifications/morning-summary/send]', error);
|
||||||
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/api/notifications/morning-summary/test/route.ts
Normal file
21
app/api/notifications/morning-summary/test/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getMorningSummaryService } from '@/lib/services/morning-summary-service';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { webhookId } = body as { webhookId: number };
|
||||||
|
|
||||||
|
if (!webhookId) {
|
||||||
|
return NextResponse.json({ error: 'webhookId is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = getMorningSummaryService();
|
||||||
|
const result = await service.testSend(webhookId);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: result.success, result });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[POST /api/notifications/morning-summary/test]', error);
|
||||||
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/api/notifications/morning-summary/webhooks/[id]/route.ts
Normal file
33
app/api/notifications/morning-summary/webhooks/[id]/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getMorningSummaryService } from '@/lib/services/morning-summary-service';
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id: rawId } = await params;
|
||||||
|
const id = parseInt(rawId, 10);
|
||||||
|
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const service = getMorningSummaryService();
|
||||||
|
const webhook = await service.updateWebhook(id, body);
|
||||||
|
return NextResponse.json({ webhook });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PUT /api/notifications/morning-summary/webhooks/:id]', error);
|
||||||
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id: rawId } = await params;
|
||||||
|
const id = parseInt(rawId, 10);
|
||||||
|
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||||
|
|
||||||
|
const service = getMorningSummaryService();
|
||||||
|
await service.deleteWebhook(id);
|
||||||
|
return NextResponse.json({ deleted: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DELETE /api/notifications/morning-summary/webhooks/:id]', error);
|
||||||
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/api/notifications/morning-summary/webhooks/route.ts
Normal file
31
app/api/notifications/morning-summary/webhooks/route.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getMorningSummaryService } from '@/lib/services/morning-summary-service';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const service = getMorningSummaryService();
|
||||||
|
const webhooks = await service.getWebhooks();
|
||||||
|
return NextResponse.json({ webhooks });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GET /api/notifications/morning-summary/webhooks]', error);
|
||||||
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { label, webhook_url } = body as { label: string; webhook_url: string };
|
||||||
|
|
||||||
|
if (!label || !webhook_url) {
|
||||||
|
return NextResponse.json({ error: 'label and webhook_url are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = getMorningSummaryService();
|
||||||
|
const webhook = await service.createWebhook(label, webhook_url);
|
||||||
|
return NextResponse.json({ webhook }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[POST /api/notifications/morning-summary/webhooks]', error);
|
||||||
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,13 +40,102 @@ export async function GET() {
|
||||||
'SELECT MAX(computed_at) as computed_at FROM veeam_compliance_results'
|
'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(`
|
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
|
SELECT
|
||||||
cr.*,
|
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
|
FROM veeam_compliance_results cr
|
||||||
LEFT JOIN companies c ON c.id = cr.company_id
|
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
|
ORDER BY cr.mismatch_type, c.company_name, cr.device_name
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
|
||||||
181
app/api/veeam/contract-coverage/route.ts
Normal file
181
app/api/veeam/contract-coverage/route.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
/**
|
||||||
|
* Contract Coverage API
|
||||||
|
* GET /api/veeam/contract-coverage
|
||||||
|
* Returns per-company contracted vs deployed counts for Servers, Workstations, M365
|
||||||
|
* plus the individual contract service lines for the expanded view.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import postgresClient from '@/lib/services/postgres-client';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const result = await postgresClient.query(`
|
||||||
|
WITH
|
||||||
|
|
||||||
|
-- All active contract service lines with category classification
|
||||||
|
cs_lines AS (
|
||||||
|
SELECT
|
||||||
|
ct.company_id,
|
||||||
|
ct.id AS contract_id,
|
||||||
|
ct.contract_name,
|
||||||
|
cs.id AS cs_id,
|
||||||
|
cs.service_name AS line_name,
|
||||||
|
cs.unit_price,
|
||||||
|
cs.unit_cost,
|
||||||
|
CASE
|
||||||
|
WHEN cs.service_name ILIKE '%windows server%'
|
||||||
|
OR cs.service_name ILIKE '%server virtual%'
|
||||||
|
OR cs.service_name ILIKE '%server phys%'
|
||||||
|
OR cs.service_name ILIKE '%esxi host%'
|
||||||
|
OR cs.service_name ILIKE '%wulf 365 it complete server%'
|
||||||
|
OR cs.service_name ILIKE '%wulf it complete (server)%'
|
||||||
|
THEN 'server'
|
||||||
|
WHEN cs.service_name ILIKE '%workstation%backup%'
|
||||||
|
OR cs.service_name ILIKE '%w/ backup%'
|
||||||
|
OR cs.service_name ILIKE '%wulf 365 it complete endpoint%'
|
||||||
|
OR cs.service_name ILIKE '%wulf it complete (endpoint)%'
|
||||||
|
THEN 'workstation'
|
||||||
|
WHEN cs.service_name ILIKE '%microsoft 365%'
|
||||||
|
OR cs.service_name ILIKE '%office 365%'
|
||||||
|
OR cs.service_name ILIKE '%exchange online%'
|
||||||
|
OR cs.service_name ILIKE '%m365%'
|
||||||
|
OR cs.service_name ILIKE '%veeam backup for microsoft office 365%'
|
||||||
|
THEN 'm365'
|
||||||
|
ELSE 'other'
|
||||||
|
END AS category
|
||||||
|
FROM contract_services cs
|
||||||
|
JOIN contracts ct ON ct.id = cs.contract_id
|
||||||
|
WHERE cs.is_deleted = false
|
||||||
|
AND ct.is_deleted = false
|
||||||
|
AND ct.status = 1
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Summarise contracted counts per company
|
||||||
|
contracted AS (
|
||||||
|
SELECT
|
||||||
|
company_id,
|
||||||
|
COUNT(*) FILTER (WHERE category = 'server') AS contracted_servers,
|
||||||
|
COUNT(*) FILTER (WHERE category = 'workstation') AS contracted_workstations,
|
||||||
|
COUNT(*) FILTER (WHERE category = 'm365') AS contracted_m365,
|
||||||
|
COUNT(*) FILTER (WHERE category = 'other') AS contracted_other,
|
||||||
|
COUNT(*) AS contracted_total
|
||||||
|
FROM cs_lines
|
||||||
|
GROUP BY company_id
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Deployed counts from Veeam agent jobs
|
||||||
|
deployed AS (
|
||||||
|
SELECT
|
||||||
|
o.company_id,
|
||||||
|
COUNT(*) FILTER (WHERE j.operation_mode = 'Server') AS deployed_servers,
|
||||||
|
COUNT(*) FILTER (WHERE j.operation_mode = 'Workstation') AS deployed_workstations,
|
||||||
|
COUNT(*) FILTER (WHERE j.operation_mode NOT IN ('Server','Workstation')) AS deployed_other
|
||||||
|
FROM veeam_backup_agent_jobs j
|
||||||
|
JOIN veeam_organizations o ON o.instance_uid = j.organization_uid
|
||||||
|
WHERE j.is_enabled = true
|
||||||
|
GROUP BY o.company_id
|
||||||
|
),
|
||||||
|
|
||||||
|
-- All companies that appear in either side
|
||||||
|
all_companies AS (
|
||||||
|
SELECT company_id FROM contracted
|
||||||
|
UNION
|
||||||
|
SELECT company_id FROM deployed
|
||||||
|
)
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
ac.company_id,
|
||||||
|
c.company_name,
|
||||||
|
COALESCE(ct.contracted_servers, 0) AS contracted_servers,
|
||||||
|
COALESCE(ct.contracted_workstations, 0) AS contracted_workstations,
|
||||||
|
COALESCE(ct.contracted_m365, 0) AS contracted_m365,
|
||||||
|
COALESCE(ct.contracted_other, 0) AS contracted_other,
|
||||||
|
COALESCE(d.deployed_servers, 0) AS deployed_servers,
|
||||||
|
COALESCE(d.deployed_workstations, 0) AS deployed_workstations,
|
||||||
|
COALESCE(d.deployed_other, 0) AS deployed_other
|
||||||
|
FROM all_companies ac
|
||||||
|
JOIN companies c ON c.id = ac.company_id
|
||||||
|
LEFT JOIN contracted ct ON ct.company_id = ac.company_id
|
||||||
|
LEFT JOIN deployed d ON d.company_id = ac.company_id
|
||||||
|
ORDER BY c.company_name
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Build per-company service lines map
|
||||||
|
const linesResult = await postgresClient.query(`
|
||||||
|
SELECT
|
||||||
|
ct.company_id,
|
||||||
|
ct.id AS contract_id,
|
||||||
|
ct.contract_name,
|
||||||
|
cs.id AS cs_id,
|
||||||
|
cs.service_name AS line_name,
|
||||||
|
cs.unit_price,
|
||||||
|
cs.unit_cost,
|
||||||
|
CASE
|
||||||
|
WHEN cs.service_name ILIKE '%windows server%'
|
||||||
|
OR cs.service_name ILIKE '%server virtual%'
|
||||||
|
OR cs.service_name ILIKE '%server phys%'
|
||||||
|
OR cs.service_name ILIKE '%esxi host%'
|
||||||
|
OR cs.service_name ILIKE '%wulf 365 it complete server%'
|
||||||
|
OR cs.service_name ILIKE '%wulf it complete (server)%'
|
||||||
|
THEN 'server'
|
||||||
|
WHEN cs.service_name ILIKE '%workstation%backup%'
|
||||||
|
OR cs.service_name ILIKE '%w/ backup%'
|
||||||
|
OR cs.service_name ILIKE '%wulf 365 it complete endpoint%'
|
||||||
|
OR cs.service_name ILIKE '%wulf it complete (endpoint)%'
|
||||||
|
THEN 'workstation'
|
||||||
|
WHEN cs.service_name ILIKE '%microsoft 365%'
|
||||||
|
OR cs.service_name ILIKE '%office 365%'
|
||||||
|
OR cs.service_name ILIKE '%exchange online%'
|
||||||
|
OR cs.service_name ILIKE '%m365%'
|
||||||
|
OR cs.service_name ILIKE '%veeam backup for microsoft office 365%'
|
||||||
|
THEN 'm365'
|
||||||
|
ELSE 'other'
|
||||||
|
END AS category
|
||||||
|
FROM contract_services cs
|
||||||
|
JOIN contracts ct ON ct.id = cs.contract_id
|
||||||
|
WHERE cs.is_deleted = false
|
||||||
|
AND ct.is_deleted = false
|
||||||
|
AND ct.status = 1
|
||||||
|
ORDER BY ct.company_id, ct.contract_name, cs.service_name
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Group lines by company_id
|
||||||
|
const linesByCompany: Record<number, typeof linesResult.rows> = {};
|
||||||
|
for (const row of linesResult.rows) {
|
||||||
|
const cid = row.company_id;
|
||||||
|
if (!linesByCompany[cid]) linesByCompany[cid] = [];
|
||||||
|
linesByCompany[cid].push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = result.rows.map((r) => ({
|
||||||
|
company_id: r.company_id,
|
||||||
|
company_name: r.company_name,
|
||||||
|
contracted: {
|
||||||
|
servers: Number(r.contracted_servers),
|
||||||
|
workstations: Number(r.contracted_workstations),
|
||||||
|
m365: Number(r.contracted_m365),
|
||||||
|
other: Number(r.contracted_other),
|
||||||
|
},
|
||||||
|
deployed: {
|
||||||
|
servers: Number(r.deployed_servers),
|
||||||
|
workstations: Number(r.deployed_workstations),
|
||||||
|
other: Number(r.deployed_other),
|
||||||
|
},
|
||||||
|
lines: (linesByCompany[r.company_id] || []).map((l) => ({
|
||||||
|
cs_id: l.cs_id,
|
||||||
|
contract_id: l.contract_id,
|
||||||
|
contract_name: l.contract_name,
|
||||||
|
line_name: l.line_name,
|
||||||
|
unit_price: l.unit_price != null ? Number(l.unit_price) : null,
|
||||||
|
unit_cost: l.unit_cost != null ? Number(l.unit_cost) : null,
|
||||||
|
category: l.category,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ rows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[contract-coverage] error:', error);
|
||||||
|
return NextResponse.json({ rows: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
126
app/api/zabbix/create-host/route.ts
Normal file
126
app/api/zabbix/create-host/route.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
/**
|
||||||
|
* Manual Zabbix Host Creation API
|
||||||
|
* POST /api/zabbix/create-host
|
||||||
|
*
|
||||||
|
* Creates a Zabbix host with ICMP monitoring using the same logic as the
|
||||||
|
* RMM discovery flow, but with a user-supplied IP and site name instead
|
||||||
|
* of auto-discovered WAN IPs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { ZabbixClient } from '@/lib/services/zabbix-client';
|
||||||
|
import { postgresClient } from '@/lib/services/postgres-client';
|
||||||
|
import {
|
||||||
|
lookupIsp,
|
||||||
|
buildHostParams,
|
||||||
|
discoverIcmpTemplate,
|
||||||
|
} from '@/lib/services/zabbix-wan-utils';
|
||||||
|
|
||||||
|
interface CreateHostBody {
|
||||||
|
ip: string;
|
||||||
|
siteName: string;
|
||||||
|
companyId?: number;
|
||||||
|
dryRun?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body: CreateHostBody = await request.json();
|
||||||
|
const { ip, siteName, companyId, dryRun = false } = body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!ip || !siteName) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ip and siteName are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic IPv4 validation
|
||||||
|
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||||
|
if (!ipv4Regex.test(ip)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid IPv4 address format' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Zabbix is not configured. Add ZABBIX_API_URL and ZABBIX_API_TOKEN to your environment.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve company name if companyId provided
|
||||||
|
let companyName: string | undefined;
|
||||||
|
if (companyId) {
|
||||||
|
const res = await postgresClient.query<{ company_name: string }>(
|
||||||
|
'SELECT company_name FROM companies WHERE id = $1 LIMIT 1',
|
||||||
|
[companyId]
|
||||||
|
);
|
||||||
|
companyName = res.rows[0]?.company_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISP lookup
|
||||||
|
const ispInfo = await lookupIsp(ip);
|
||||||
|
|
||||||
|
// Dry-run: return what would happen without writing to Zabbix
|
||||||
|
if (dryRun) {
|
||||||
|
return NextResponse.json({
|
||||||
|
action: 'skipped',
|
||||||
|
dryRun: true,
|
||||||
|
siteName,
|
||||||
|
ip,
|
||||||
|
companyId: companyId ?? null,
|
||||||
|
companyName: companyName ?? null,
|
||||||
|
isp: ispInfo?.isp ?? null,
|
||||||
|
asn: ispInfo?.asn ?? null,
|
||||||
|
hostId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const zabbix = new ZabbixClient({
|
||||||
|
apiUrl: process.env.ZABBIX_API_URL!,
|
||||||
|
apiToken: process.env.ZABBIX_API_TOKEN!,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure host group + discover ICMP template
|
||||||
|
const globalGroupId = await zabbix.ensureHostGroup('Datto RMM Sites');
|
||||||
|
const icmpTemplateId = await discoverIcmpTemplate(zabbix);
|
||||||
|
|
||||||
|
// Build host params using the shared utility
|
||||||
|
const hostParams = await buildHostParams({
|
||||||
|
siteName,
|
||||||
|
wanIp: ip,
|
||||||
|
companyId,
|
||||||
|
companyName,
|
||||||
|
ispInfo,
|
||||||
|
source: 'manual',
|
||||||
|
icmpTemplateId,
|
||||||
|
globalGroupId,
|
||||||
|
zabbix,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create or update the host
|
||||||
|
const { action, hostid } = await zabbix.upsertHost(hostParams);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
action,
|
||||||
|
dryRun: false,
|
||||||
|
siteName,
|
||||||
|
ip,
|
||||||
|
companyId: companyId ?? null,
|
||||||
|
companyName: companyName ?? null,
|
||||||
|
isp: ispInfo?.isp ?? null,
|
||||||
|
asn: ispInfo?.asn ?? null,
|
||||||
|
hostId: hostid,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[create-host] error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: String(error) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
app/api/zabbix/hosts/[hostid]/route.ts
Normal file
142
app/api/zabbix/hosts/[hostid]/route.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
/**
|
||||||
|
* PUT /api/zabbix/hosts/[hostid] — full update of an existing Zabbix host
|
||||||
|
*
|
||||||
|
* If companyId is provided (or changed), rebuilds host groups / macros / tags
|
||||||
|
* via buildHostParams (same as creation flow). Otherwise applies fields directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { ZabbixClient } from '@/lib/services/zabbix-client';
|
||||||
|
import { postgresClient } from '@/lib/services/postgres-client';
|
||||||
|
import { ZabbixHostMacro, ZabbixHostTag } from '@/lib/types/zabbix';
|
||||||
|
import {
|
||||||
|
sanitizeHostname,
|
||||||
|
lookupIsp,
|
||||||
|
buildHostParams,
|
||||||
|
discoverIcmpTemplate,
|
||||||
|
} from '@/lib/services/zabbix-wan-utils';
|
||||||
|
|
||||||
|
interface UpdateBody {
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
description?: string;
|
||||||
|
companyId?: number | null;
|
||||||
|
tags?: ZabbixHostTag[];
|
||||||
|
macros?: ZabbixHostMacro[];
|
||||||
|
rebuildFromClient?: boolean; // if true, fully re-run buildHostParams
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ hostid: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { hostid } = await params;
|
||||||
|
const body: UpdateBody = await request.json();
|
||||||
|
const { name, ip, description, companyId, tags, macros, rebuildFromClient } = body;
|
||||||
|
|
||||||
|
if (!name || !ip) {
|
||||||
|
return NextResponse.json({ error: 'name and ip are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||||
|
if (!ipv4Regex.test(ip)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid IPv4 address' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) {
|
||||||
|
return NextResponse.json({ error: 'Zabbix not configured' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const zabbix = new ZabbixClient({
|
||||||
|
apiUrl: process.env.ZABBIX_API_URL!,
|
||||||
|
apiToken: process.env.ZABBIX_API_TOKEN!,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rebuildFromClient) {
|
||||||
|
// Full rebuild: re-resolve ISP, rebuild groups/macros/tags from scratch
|
||||||
|
let companyName: string | undefined;
|
||||||
|
if (companyId) {
|
||||||
|
const res = await postgresClient.query<{ company_name: string }>(
|
||||||
|
'SELECT company_name FROM companies WHERE id = $1 LIMIT 1',
|
||||||
|
[companyId]
|
||||||
|
);
|
||||||
|
companyName = res.rows[0]?.company_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ispInfo = await lookupIsp(ip);
|
||||||
|
const globalGroupId = await zabbix.ensureHostGroup('Datto RMM Sites');
|
||||||
|
const icmpTemplateId = await discoverIcmpTemplate(zabbix);
|
||||||
|
|
||||||
|
const hostParams = await buildHostParams({
|
||||||
|
siteName: name,
|
||||||
|
wanIp: ip,
|
||||||
|
companyId: companyId ?? undefined,
|
||||||
|
companyName,
|
||||||
|
ispInfo,
|
||||||
|
source: 'manual',
|
||||||
|
icmpTemplateId,
|
||||||
|
globalGroupId,
|
||||||
|
zabbix,
|
||||||
|
});
|
||||||
|
|
||||||
|
await zabbix['rpc']('host.update', {
|
||||||
|
hostid,
|
||||||
|
host: sanitizeHostname(name),
|
||||||
|
name,
|
||||||
|
description: description ?? hostParams.description,
|
||||||
|
groups: hostParams.groups,
|
||||||
|
templates: hostParams.templates,
|
||||||
|
macros: hostParams.macros,
|
||||||
|
tags: hostParams.tags,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update IP interface separately
|
||||||
|
const existingHost = await zabbix['rpc']<Array<{ interfaces: Array<{ interfaceid: string; main: number }> }>>('host.get', {
|
||||||
|
output: ['hostid'],
|
||||||
|
hostids: [hostid],
|
||||||
|
selectInterfaces: ['interfaceid', 'main', 'type'],
|
||||||
|
});
|
||||||
|
const mainIface = existingHost[0]?.interfaces?.find((i: any) => i.main === 1 || i.main === '1');
|
||||||
|
if (mainIface) {
|
||||||
|
await zabbix['rpc']('hostinterface.update', {
|
||||||
|
interfaceid: mainIface.interfaceid,
|
||||||
|
ip,
|
||||||
|
useip: 1,
|
||||||
|
dns: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Direct update — apply exactly what was sent
|
||||||
|
await zabbix['rpc']('host.update', {
|
||||||
|
hostid,
|
||||||
|
host: sanitizeHostname(name),
|
||||||
|
name,
|
||||||
|
...(description !== undefined ? { description } : {}),
|
||||||
|
...(tags !== undefined ? { tags } : {}),
|
||||||
|
...(macros !== undefined ? { macros } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update IP interface
|
||||||
|
const existingHost = await zabbix['rpc']<Array<{ interfaces: Array<{ interfaceid: string; main: number }> }>>('host.get', {
|
||||||
|
output: ['hostid'],
|
||||||
|
hostids: [hostid],
|
||||||
|
selectInterfaces: ['interfaceid', 'main', 'type'],
|
||||||
|
});
|
||||||
|
const mainIface = existingHost[0]?.interfaces?.find((i: any) => i.main === 1 || i.main === '1');
|
||||||
|
if (mainIface) {
|
||||||
|
await zabbix['rpc']('hostinterface.update', {
|
||||||
|
interfaceid: mainIface.interfaceid,
|
||||||
|
ip,
|
||||||
|
useip: 1,
|
||||||
|
dns: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ updated: true, hostid });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PUT /api/zabbix/hosts/[hostid]]', error);
|
||||||
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/api/zabbix/hosts/route.ts
Normal file
68
app/api/zabbix/hosts/route.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
/**
|
||||||
|
* GET /api/zabbix/hosts — list all hosts with full detail + RMM match status
|
||||||
|
* DELETE /api/zabbix/hosts — bulk delete by hostid array
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { ZabbixClient } from '@/lib/services/zabbix-client';
|
||||||
|
import { postgresClient } from '@/lib/services/postgres-client';
|
||||||
|
|
||||||
|
function makeZabbix() {
|
||||||
|
if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) {
|
||||||
|
throw new Error('Zabbix is not configured. Set ZABBIX_API_URL and ZABBIX_API_TOKEN.');
|
||||||
|
}
|
||||||
|
return new ZabbixClient({
|
||||||
|
apiUrl: process.env.ZABBIX_API_URL!,
|
||||||
|
apiToken: process.env.ZABBIX_API_TOKEN!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const zabbix = makeZabbix();
|
||||||
|
const hosts = await zabbix.getHosts();
|
||||||
|
|
||||||
|
// Load all known RMM site UIDs from Postgres for mismatch detection
|
||||||
|
const res = await postgresClient.query<{ rmm_site_uid: string }>(
|
||||||
|
'SELECT rmm_site_uid FROM rmm_site_mappings'
|
||||||
|
);
|
||||||
|
const knownSiteUids = new Set(res.rows.map((r) => r.rmm_site_uid));
|
||||||
|
|
||||||
|
// Annotate each host with rmmMatched flag
|
||||||
|
const annotated = hosts.map((h) => {
|
||||||
|
const rmmUidMacro = h.macros?.find((m) => m.macro === '{$RMM_SITE_UID}');
|
||||||
|
const sourceTag = h.tags?.find((t) => t.tag === 'source')?.value ?? null;
|
||||||
|
|
||||||
|
let rmmMatched: boolean | null = null;
|
||||||
|
if (sourceTag === 'datto-rmm') {
|
||||||
|
rmmMatched = rmmUidMacro ? knownSiteUids.has(rmmUidMacro.value) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...h, rmmMatched, sourceTag };
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ hosts: annotated });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GET /api/zabbix/hosts]', error);
|
||||||
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { hostids } = body as { hostids: string[] };
|
||||||
|
|
||||||
|
if (!Array.isArray(hostids) || hostids.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'hostids array is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const zabbix = makeZabbix();
|
||||||
|
await zabbix.deleteHosts(hostids);
|
||||||
|
|
||||||
|
return NextResponse.json({ deleted: hostids.length });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DELETE /api/zabbix/hosts]', error);
|
||||||
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/api/zabbix/public-ip/route.ts
Normal file
21
app/api/zabbix/public-ip/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
/**
|
||||||
|
* GET /api/zabbix/public-ip
|
||||||
|
* Returns the server's current public IP address.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://ipinfo.io/json', {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`ipinfo ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return NextResponse.json({ ip: data.ip ?? null });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ ip: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,21 +3,18 @@ import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
|
||||||
import { ZabbixClient } from '@/lib/services/zabbix-client';
|
import { ZabbixClient } from '@/lib/services/zabbix-client';
|
||||||
import { postgresClient } from '@/lib/services/postgres-client';
|
import { postgresClient } from '@/lib/services/postgres-client';
|
||||||
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
|
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;
|
export const maxDuration = 300;
|
||||||
|
|
||||||
type SyncMode = 'all' | 'client' | 'site';
|
type SyncMode = 'all' | 'client' | 'site';
|
||||||
type SiteAction = 'created' | 'updated' | 'filtered' | 'no-ip' | 'error' | 'skipped';
|
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 {
|
interface WanResolution {
|
||||||
ip: string | null;
|
ip: string | null;
|
||||||
count: number;
|
count: number;
|
||||||
|
|
@ -151,61 +148,8 @@ function resolveWanIp(
|
||||||
return { ip: topIp, count: topCount, multiWan, allIps, singleDeviceFallback };
|
return { ip: topIp, count: topCount, multiWan, allIps, singleDeviceFallback };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ISP lookup, hostname sanitization, and host param building imported from
|
||||||
// ISP lookup via ipinfo.io (free, no key required for basic fields)
|
// @/lib/services/zabbix-wan-utils
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// API route
|
// API route
|
||||||
|
|
@ -223,7 +167,7 @@ export async function POST(request: NextRequest) {
|
||||||
dryRun = false,
|
dryRun = false,
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
ispCache.clear(); // fresh cache per request
|
clearIspCache(); // fresh cache per request
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const transform = new TransformStream<Uint8Array, Uint8Array>();
|
const transform = new TransformStream<Uint8Array, Uint8Array>();
|
||||||
|
|
@ -252,10 +196,7 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
globalGroupId = await zabbix.ensureHostGroup('Datto RMM Sites');
|
globalGroupId = await zabbix.ensureHostGroup('Datto RMM Sites');
|
||||||
for (const name of ['ICMP Ping', 'Template Module ICMP Ping', 'Template Module ICMP Ping by Zabbix agent']) {
|
icmpTemplateId = await discoverIcmpTemplate(zabbix);
|
||||||
const tmpl = await zabbix.findTemplate(name);
|
|
||||||
if (tmpl) { icmpTemplateId = tmpl.templateid; break; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load site → Autotask mappings (keyed by RMM site UID)
|
// Load site → Autotask mappings (keyed by RMM site UID)
|
||||||
|
|
@ -364,75 +305,26 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const onlineCount = devices.filter((d) => d.online && !d.suspended && !d.deleted).length;
|
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 hostParams = await buildHostParams({
|
||||||
const groups: Array<{ groupid: string }> = [{ groupid: globalGroupId }];
|
siteName: site.name,
|
||||||
|
wanIp,
|
||||||
if (mapping) {
|
companyId: mapping?.companyId,
|
||||||
const clientGroupId = await zabbix.ensureHostGroup(`Clients/${mapping.companyName}`);
|
companyName: mapping?.companyName,
|
||||||
groups.push({ groupid: clientGroupId });
|
rmmSiteUid: site.uid,
|
||||||
}
|
ispInfo,
|
||||||
if (ispInfo?.isp) {
|
multiWan,
|
||||||
const ispGroupId = await zabbix.ensureHostGroup(`ISP/${ispInfo.isp}`);
|
allIps,
|
||||||
groups.push({ groupid: ispGroupId });
|
singleDeviceFallback,
|
||||||
}
|
onlineDeviceCount: onlineCount,
|
||||||
|
source: 'datto-rmm',
|
||||||
// Build macros: Autotask identity + ISP context
|
icmpTemplateId,
|
||||||
const macros: ZabbixHostMacro[] = [];
|
globalGroupId,
|
||||||
if (mapping) {
|
zabbix,
|
||||||
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 { action, hostid } = await zabbix.upsertHost(hostParams);
|
||||||
|
|
||||||
if (action === 'created') stats.created++; else stats.updated++;
|
if (action === 'created') stats.created++; else stats.updated++;
|
||||||
|
|
||||||
await send({ type: 'site', result: {
|
await send({ type: 'site', result: {
|
||||||
|
|
|
||||||
46
app/api/zoom/sync/route.ts
Normal file
46
app/api/zoom/sync/route.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { isZoomConfigured } from '@/lib/services/zoom-factory';
|
||||||
|
import { getZoomSyncService } from '@/lib/services/zoom-sync-service';
|
||||||
|
import { postgresClient } from '@/lib/services/postgres-client';
|
||||||
|
|
||||||
|
export async function POST(_request: NextRequest) {
|
||||||
|
if (!isZoomConfigured()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Zoom credentials not configured' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = getZoomSyncService();
|
||||||
|
|
||||||
|
if (service.isSyncInProgress()) {
|
||||||
|
return NextResponse.json({ error: 'Zoom sync already in progress' }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire-and-forget
|
||||||
|
service.sync().catch(err => {
|
||||||
|
console.error('[ZOOM-SYNC] Background sync failed:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ started: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest) {
|
||||||
|
const service = getZoomSyncService();
|
||||||
|
|
||||||
|
let lastSynced: Date | null = null;
|
||||||
|
try {
|
||||||
|
const result = await postgresClient.query(
|
||||||
|
`SELECT MAX(synced_at) as last_synced FROM zoom_users`
|
||||||
|
);
|
||||||
|
lastSynced = result.rows[0]?.last_synced ?? null;
|
||||||
|
} catch {
|
||||||
|
// Table may not exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
isSyncing: service.isSyncInProgress(),
|
||||||
|
lastSynced,
|
||||||
|
configured: isZoomConfigured(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -8,8 +8,11 @@ import { BackupSummaryCards } from '@/components/backup/backup-summary-cards';
|
||||||
import { CompanyBackupTable, CompanyBackupRow } from '@/components/backup/company-backup-table';
|
import { CompanyBackupTable, CompanyBackupRow } from '@/components/backup/company-backup-table';
|
||||||
import { ComplianceSummaryCards } from '@/components/backup/compliance-summary-cards';
|
import { ComplianceSummaryCards } from '@/components/backup/compliance-summary-cards';
|
||||||
import { ComplianceDetailTable } from '@/components/backup/compliance-detail-table';
|
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 { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { RpoJobSummary } from '@/lib/services/veeam-rpo-service';
|
||||||
|
|
||||||
interface BackupStatusData {
|
interface BackupStatusData {
|
||||||
totalProtectedWorkloads: number;
|
totalProtectedWorkloads: number;
|
||||||
|
|
@ -22,6 +25,18 @@ interface BackupStatusData {
|
||||||
lastSyncAt: string | null;
|
lastSyncAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RpoData {
|
||||||
|
summary: {
|
||||||
|
total: number;
|
||||||
|
healthy: number;
|
||||||
|
breached: number;
|
||||||
|
withOpenTicket: number;
|
||||||
|
critical: number;
|
||||||
|
high: number;
|
||||||
|
};
|
||||||
|
jobs: RpoJobSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
interface ComplianceData {
|
interface ComplianceData {
|
||||||
summary: {
|
summary: {
|
||||||
totalContractedDevices: number;
|
totalContractedDevices: number;
|
||||||
|
|
@ -44,23 +59,33 @@ function timeAgo(dateStr: string | null): string {
|
||||||
return `${Math.floor(hours / 24)}d ago`;
|
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() {
|
export default function BackupStatusPage() {
|
||||||
const [status, setStatus] = useState<BackupStatusData | null>(null);
|
const [status, setStatus] = useState<BackupStatusData | null>(null);
|
||||||
const [companies, setCompanies] = useState<CompanyBackupRow[]>([]);
|
const [companies, setCompanies] = useState<CompanyBackupRow[]>([]);
|
||||||
const [compliance, setCompliance] = useState<ComplianceData | null>(null);
|
const [compliance, setCompliance] = useState<ComplianceData | null>(null);
|
||||||
|
const [rpo, setRpo] = useState<RpoData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
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/backup-status').then(r => r.json()),
|
||||||
fetch('/api/veeam/companies').then(r => r.json()),
|
fetch('/api/veeam/companies').then(r => r.json()),
|
||||||
fetch('/api/veeam/compliance').then(r => r.json()),
|
fetch('/api/veeam/compliance').then(r => r.json()),
|
||||||
|
fetch('/api/veeam/rpo-check').then(r => r.json()),
|
||||||
]);
|
]);
|
||||||
setStatus(statusRes);
|
setStatus(statusRes);
|
||||||
setCompanies(Array.isArray(companiesRes) ? companiesRes : []);
|
setCompanies(Array.isArray(companiesRes) ? companiesRes : []);
|
||||||
setCompliance(complianceRes);
|
setCompliance(complianceRes);
|
||||||
|
setRpo(rpoRes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch backup status:', error);
|
console.error('Failed to fetch backup status:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -106,7 +131,7 @@ export default function BackupStatusPage() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
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">
|
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-5">
|
||||||
{[...Array(5)].map((_, i) => <Skeleton key={i} className="h-24" />)}
|
{[...Array(5)].map((_, i) => <Skeleton key={i} className="h-24" />)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -117,12 +142,20 @@ export default function BackupStatusPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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">
|
<Tabs defaultValue="overview" className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="overview">Backup Overview</TabsTrigger>
|
<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">
|
<TabsTrigger value="compliance">
|
||||||
Contract Compliance
|
Contract Compliance
|
||||||
{compliance && (compliance.summary.contractedNotBackedUp > 0 || compliance.summary.backedUpNotContracted > 0) && (
|
{compliance && (compliance.summary.contractedNotBackedUp > 0 || compliance.summary.backedUpNotContracted > 0) && (
|
||||||
|
|
@ -165,6 +198,110 @@ export default function BackupStatusPage() {
|
||||||
<CompanyBackupTable companies={companies} />
|
<CompanyBackupTable companies={companies} />
|
||||||
</TabsContent>
|
</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">
|
<TabsContent value="compliance" className="space-y-6">
|
||||||
{compliance && (
|
{compliance && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -174,7 +311,25 @@ export default function BackupStatusPage() {
|
||||||
contractedNotBackedUp={compliance.summary.contractedNotBackedUp}
|
contractedNotBackedUp={compliance.summary.contractedNotBackedUp}
|
||||||
backedUpNotContracted={compliance.summary.backedUpNotContracted}
|
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>
|
</TabsContent>
|
||||||
|
|
|
||||||
1297
app/engagement/page.tsx
Normal file
1297
app/engagement/page.tsx
Normal file
File diff suppressed because it is too large
Load diff
648
app/engagement/profile/page.tsx
Normal file
648
app/engagement/profile/page.tsx
Normal file
|
|
@ -0,0 +1,648 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
ResponsiveContainer,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
CartesianGrid,
|
||||||
|
RadarChart,
|
||||||
|
PolarGrid,
|
||||||
|
PolarAngleAxis,
|
||||||
|
PolarRadiusAxis,
|
||||||
|
Radar,
|
||||||
|
} from 'recharts';
|
||||||
|
import { Users, RefreshCw } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface UserOption {
|
||||||
|
graphUserId: string;
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
jobTitle: string | null;
|
||||||
|
department: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DayData {
|
||||||
|
date: string;
|
||||||
|
hoursWorked: number;
|
||||||
|
billableHours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonthData {
|
||||||
|
month: string;
|
||||||
|
hoursWorked: number;
|
||||||
|
billableHours: number;
|
||||||
|
daysWorked: number;
|
||||||
|
teamsMessages: number;
|
||||||
|
teamsPrivateMessages: number;
|
||||||
|
teamsCalls: number;
|
||||||
|
meetingsAttended: number;
|
||||||
|
meetingsOrganized: number;
|
||||||
|
emailsSent: number;
|
||||||
|
emailsReceived: number;
|
||||||
|
totalMeetings: number;
|
||||||
|
clientMeetings: number;
|
||||||
|
meetingDurationMinutes: number;
|
||||||
|
zoomCalls: number;
|
||||||
|
zoomClientCalls: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryData {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
jobTitle: string | null;
|
||||||
|
department: string | null;
|
||||||
|
autotaskResourceId: number | null;
|
||||||
|
};
|
||||||
|
daily: DayData[];
|
||||||
|
monthly: MonthData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heat level class names — must be full strings for Tailwind to include them
|
||||||
|
const HEAT_CLASSES = [
|
||||||
|
'bg-muted/50',
|
||||||
|
'bg-emerald-100 dark:bg-emerald-950',
|
||||||
|
'bg-emerald-300 dark:bg-emerald-800',
|
||||||
|
'bg-emerald-500 dark:bg-emerald-600',
|
||||||
|
'bg-emerald-700 dark:bg-emerald-400',
|
||||||
|
];
|
||||||
|
|
||||||
|
function hoursLevel(h: number): number {
|
||||||
|
if (h <= 0) return 0;
|
||||||
|
if (h < 2) return 1;
|
||||||
|
if (h < 5) return 2;
|
||||||
|
if (h < 7) return 3;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivityHeatmap({ daily }: { daily: DayData[] }) {
|
||||||
|
const dailyMap: Record<string, DayData> = {};
|
||||||
|
for (const d of daily) dailyMap[d.date] = d;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// Start from 52 weeks ago, padded back to Monday
|
||||||
|
const startDate = new Date(today);
|
||||||
|
startDate.setDate(startDate.getDate() - 363);
|
||||||
|
const dow = (startDate.getDay() + 6) % 7; // Mon=0 … Sun=6
|
||||||
|
startDate.setDate(startDate.getDate() - dow);
|
||||||
|
|
||||||
|
const yearAgo = new Date(today);
|
||||||
|
yearAgo.setFullYear(yearAgo.getFullYear() - 1);
|
||||||
|
|
||||||
|
// Build weeks
|
||||||
|
const weeks: Array<Array<{ date: string; inRange: boolean }>> = [];
|
||||||
|
const cursor = new Date(startDate);
|
||||||
|
while (cursor <= today) {
|
||||||
|
const week: Array<{ date: string; inRange: boolean }> = [];
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const key = cursor.toISOString().slice(0, 10);
|
||||||
|
week.push({ date: key, inRange: cursor >= yearAgo && cursor <= today });
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
}
|
||||||
|
weeks.push(week);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Month label: track where each month starts
|
||||||
|
const monthLabels: Array<{ weekIndex: number; label: string }> = [];
|
||||||
|
let lastMonth = -1;
|
||||||
|
weeks.forEach((week, wi) => {
|
||||||
|
const d = new Date(week[0].date + 'T00:00:00');
|
||||||
|
const m = d.getMonth();
|
||||||
|
if (m !== lastMonth) {
|
||||||
|
monthLabels.push({
|
||||||
|
weekIndex: wi,
|
||||||
|
label: d.toLocaleDateString('en-US', { month: 'short' }),
|
||||||
|
});
|
||||||
|
lastMonth = m;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const DAY_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', 'Sun'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto pb-2">
|
||||||
|
<div className="inline-flex gap-2 min-w-0">
|
||||||
|
{/* Day labels */}
|
||||||
|
<div className="flex flex-col gap-[3px] pt-5 shrink-0">
|
||||||
|
{DAY_LABELS.map((label, i) => (
|
||||||
|
<div key={i} className="h-[13px] flex items-center">
|
||||||
|
<span className="text-[10px] text-muted-foreground w-6 text-right pr-1 leading-none">{label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{/* Month labels */}
|
||||||
|
<div className="flex mb-1 h-4 relative">
|
||||||
|
{weeks.map((_, wi) => {
|
||||||
|
const ml = monthLabels.find(m => m.weekIndex === wi);
|
||||||
|
return (
|
||||||
|
<div key={wi} className="w-[13px] shrink-0 relative">
|
||||||
|
{ml && (
|
||||||
|
<span className="absolute text-[10px] text-muted-foreground whitespace-nowrap leading-none">
|
||||||
|
{ml.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cells */}
|
||||||
|
<div className="flex gap-[3px]">
|
||||||
|
{weeks.map((week, wi) => (
|
||||||
|
<div key={wi} className="flex flex-col gap-[3px]">
|
||||||
|
{week.map((cell, di) => {
|
||||||
|
const data = dailyMap[cell.date];
|
||||||
|
const hours = data?.hoursWorked ?? 0;
|
||||||
|
const level = cell.inRange ? hoursLevel(hours) : 0;
|
||||||
|
const isFuture = cell.date > today.toISOString().slice(0, 10);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={di}
|
||||||
|
className={cn(
|
||||||
|
'w-[13px] h-[13px] rounded-[2px]',
|
||||||
|
isFuture ? 'opacity-0' : HEAT_CLASSES[level]
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
cell.inRange && !isFuture
|
||||||
|
? `${cell.date}: ${hours.toFixed(1)}h (${(data?.billableHours ?? 0).toFixed(1)}h billable)`
|
||||||
|
: cell.date
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex items-center gap-1.5 mt-3 ml-8">
|
||||||
|
<span className="text-[10px] text-muted-foreground">Less</span>
|
||||||
|
{HEAT_CLASSES.map((cls, i) => (
|
||||||
|
<div key={i} className={cn('w-[13px] h-[13px] rounded-[2px]', cls)} />
|
||||||
|
))}
|
||||||
|
<span className="text-[10px] text-muted-foreground">More</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRadarData(monthly: MonthData[]) {
|
||||||
|
const active = monthly.filter(m => m.hoursWorked > 0 || m.teamsMessages > 0 || m.emailsSent > 0);
|
||||||
|
if (active.length === 0) return [];
|
||||||
|
|
||||||
|
const avg = (fn: (m: MonthData) => number) =>
|
||||||
|
active.reduce((s, m) => s + fn(m), 0) / active.length;
|
||||||
|
|
||||||
|
const avgHours = avg(m => m.hoursWorked);
|
||||||
|
const avgBillable = avg(m => m.billableHours);
|
||||||
|
const avgMeetings = avg(m => m.totalMeetings);
|
||||||
|
const avgComms = avg(m => m.teamsMessages + m.emailsSent);
|
||||||
|
const avgCalls = avg(m => m.zoomClientCalls + m.teamsCalls);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
subject: 'Utilization',
|
||||||
|
value: Math.min(100, Math.round((avgHours / 160) * 100)),
|
||||||
|
fullMark: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subject: 'Billable %',
|
||||||
|
value: avgHours > 0 ? Math.round((avgBillable / avgHours) * 100) : 0,
|
||||||
|
fullMark: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subject: 'Meetings',
|
||||||
|
value: Math.min(100, Math.round((avgMeetings / 25) * 100)),
|
||||||
|
fullMark: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subject: 'Comms',
|
||||||
|
value: Math.min(100, Math.round((avgComms / 400) * 100)),
|
||||||
|
fullMark: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subject: 'Client Calls',
|
||||||
|
value: Math.min(100, Math.round((avgCalls / 15) * 100)),
|
||||||
|
fullMark: 100,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthLabel(m: string) {
|
||||||
|
return new Date(m + '-02').toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackfillStatus {
|
||||||
|
running: boolean;
|
||||||
|
started: string | null;
|
||||||
|
processed: number;
|
||||||
|
total: number;
|
||||||
|
currentUser: string | null;
|
||||||
|
errors: number;
|
||||||
|
done: boolean;
|
||||||
|
log: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EngagementProfilePage() {
|
||||||
|
const [users, setUsers] = useState<UserOption[]>([]);
|
||||||
|
const [usersLoading, setUsersLoading] = useState(true);
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||||
|
const [history, setHistory] = useState<HistoryData | null>(null);
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false);
|
||||||
|
const [backfill, setBackfill] = useState<BackfillStatus | null>(null);
|
||||||
|
const [backfillStarting, setBackfillStarting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/engagement/users?period=D30&sort=display_name&order=asc');
|
||||||
|
const data = await res.json();
|
||||||
|
setUsers(data.users ?? []);
|
||||||
|
} catch {}
|
||||||
|
setUsersLoading(false);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadHistory = useCallback(async (userId: string) => {
|
||||||
|
setHistoryLoading(true);
|
||||||
|
setHistory(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/engagement/user/${userId}/history`);
|
||||||
|
const data = await res.json();
|
||||||
|
setHistory(data);
|
||||||
|
} catch {}
|
||||||
|
setHistoryLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = (userId: string) => {
|
||||||
|
setSelectedUserId(userId);
|
||||||
|
loadHistory(userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startBackfill = async () => {
|
||||||
|
setBackfillStarting(true);
|
||||||
|
try {
|
||||||
|
await fetch('/api/engagement/backfill-meetings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ monthsBack: 12 }),
|
||||||
|
});
|
||||||
|
pollBackfill();
|
||||||
|
} catch {}
|
||||||
|
setBackfillStarting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pollBackfill = useCallback(async () => {
|
||||||
|
const res = await fetch('/api/engagement/backfill-meetings').catch(() => null);
|
||||||
|
if (!res) return;
|
||||||
|
const data: BackfillStatus = await res.json();
|
||||||
|
setBackfill(data);
|
||||||
|
if (data.running) setTimeout(pollBackfill, 2000);
|
||||||
|
else if (data.done && selectedUserId) loadHistory(selectedUserId);
|
||||||
|
}, [selectedUserId, loadHistory]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/engagement/backfill-meetings').then(r => r.json()).then((d: BackfillStatus) => {
|
||||||
|
setBackfill(d);
|
||||||
|
if (d.running) setTimeout(pollBackfill, 2000);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [pollBackfill]);
|
||||||
|
|
||||||
|
const monthly = history?.monthly ?? [];
|
||||||
|
const radarData = buildRadarData(monthly);
|
||||||
|
|
||||||
|
const totalHours = monthly.reduce((s, m) => s + m.hoursWorked, 0);
|
||||||
|
const totalBillable = monthly.reduce((s, m) => s + m.billableHours, 0);
|
||||||
|
const billablePct = totalHours > 0 ? Math.round((totalBillable / totalHours) * 100) : 0;
|
||||||
|
const activeMonths = monthly.filter(m => m.hoursWorked > 0).length;
|
||||||
|
const peakMonth = monthly.reduce(
|
||||||
|
(best, m) => (m.hoursWorked > (best?.hoursWorked ?? 0) ? m : best),
|
||||||
|
null as MonthData | null
|
||||||
|
);
|
||||||
|
|
||||||
|
const barData = monthly.map(m => ({
|
||||||
|
name: m.month.slice(5),
|
||||||
|
billable: parseFloat(m.billableHours.toFixed(1)),
|
||||||
|
nonBillable: parseFloat((m.hoursWorked - m.billableHours).toFixed(1)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-6 py-8 space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Employee Profile</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mt-1">12-month activity overview</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select value={selectedUserId} onValueChange={handleSelect} disabled={usersLoading}>
|
||||||
|
<SelectTrigger className="w-64">
|
||||||
|
<SelectValue placeholder={usersLoading ? 'Loading…' : 'Select employee…'} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{users.map(u => (
|
||||||
|
<SelectItem key={u.graphUserId} value={u.graphUserId}>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span>{u.displayName}</span>
|
||||||
|
{u.jobTitle && (
|
||||||
|
<span className="text-xs text-muted-foreground">{u.jobTitle}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Backfill panel */}
|
||||||
|
{backfill && (backfill.running || backfill.done) ? (
|
||||||
|
<Card className={cn('border', backfill.running ? 'border-blue-400' : backfill.errors > 0 ? 'border-yellow-400' : 'border-green-400')}>
|
||||||
|
<CardContent className="py-3 px-4 space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
{backfill.running && <RefreshCw className="h-4 w-4 animate-spin text-blue-500" />}
|
||||||
|
{backfill.running
|
||||||
|
? `Backfilling meetings… ${backfill.processed}/${backfill.total} users`
|
||||||
|
: `Backfill complete — ${backfill.processed} users, ${backfill.errors} errors`}
|
||||||
|
</div>
|
||||||
|
{backfill.running && backfill.currentUser && (
|
||||||
|
<span className="text-xs text-muted-foreground truncate">{backfill.currentUser}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{backfill.running && backfill.total > 0 && (
|
||||||
|
<div className="w-full bg-muted rounded-full h-1.5">
|
||||||
|
<div
|
||||||
|
className="bg-blue-500 h-1.5 rounded-full transition-all"
|
||||||
|
style={{ width: `${Math.round((backfill.processed / backfill.total) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{backfill.log.length > 0 && (
|
||||||
|
<pre className="text-[10px] text-muted-foreground max-h-24 overflow-y-auto bg-muted/30 rounded p-2">
|
||||||
|
{backfill.log.slice(-20).join('\n')}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={startBackfill}
|
||||||
|
disabled={backfillStarting}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-3.5 w-3.5', backfillStarting && 'animate-spin')} />
|
||||||
|
Backfill 12 months of meetings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selectedUserId && (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
|
||||||
|
<Users className="h-10 w-10 text-muted-foreground mb-4" />
|
||||||
|
<p className="font-medium">Select an employee</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Choose an employee above to see their 12-month activity profile
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedUserId && historyLoading && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-48" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedUserId && !historyLoading && history && (
|
||||||
|
<>
|
||||||
|
{/* User header */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">{history.user.displayName}</h2>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 mt-0.5 text-sm text-muted-foreground">
|
||||||
|
{history.user.jobTitle && <span>{history.user.jobTitle}</span>}
|
||||||
|
{history.user.jobTitle && history.user.department && <span>·</span>}
|
||||||
|
{history.user.department && <span>{history.user.department}</span>}
|
||||||
|
{(history.user.jobTitle || history.user.department) && <span>·</span>}
|
||||||
|
<span>{history.user.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Year stats */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 pb-4">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">Total Hours</p>
|
||||||
|
<p className="text-2xl font-bold tabular-nums">{totalHours.toFixed(0)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">over 12 months</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 pb-4">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">Billable Rate</p>
|
||||||
|
<p className="text-2xl font-bold tabular-nums">{billablePct}%</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{totalBillable.toFixed(0)}h billable</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 pb-4">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">Avg hrs / month</p>
|
||||||
|
<p className="text-2xl font-bold tabular-nums">
|
||||||
|
{activeMonths > 0 ? (totalHours / activeMonths).toFixed(0) : '—'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{activeMonths} active months</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 pb-4">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">Peak Month</p>
|
||||||
|
<p className="text-2xl font-bold tabular-nums">
|
||||||
|
{peakMonth ? peakMonth.hoursWorked.toFixed(0) + 'h' : '—'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{peakMonth ? monthLabel(peakMonth.month) : ''}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity heatmap */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Activity Calendar</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">Daily hours worked — last 12 months</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{history.daily.length > 0 ? (
|
||||||
|
<ActivityHeatmap daily={history.daily} />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground py-6 text-center">
|
||||||
|
No time entry data available
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Monthly bar chart + radar */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Monthly Hours</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">Billable vs non-billable</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<BarChart data={barData} margin={{ top: 5, right: 10, left: -20, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||||
|
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value, name) => [
|
||||||
|
`${value}h`,
|
||||||
|
name === 'billable' ? 'Billable' : 'Non-billable',
|
||||||
|
]}
|
||||||
|
contentStyle={{ fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
formatter={v => (v === 'billable' ? 'Billable' : 'Non-billable')}
|
||||||
|
wrapperStyle={{ fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="billable" stackId="a" fill="#10b981" name="billable" />
|
||||||
|
<Bar dataKey="nonBillable" stackId="a" fill="#94a3b8" name="nonBillable" radius={[2, 2, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Activity Signature</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">12-month average profile</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex items-center justify-center pt-0">
|
||||||
|
{radarData.length > 0 ? (
|
||||||
|
<RadarChart width={240} height={220} data={radarData}>
|
||||||
|
<PolarGrid />
|
||||||
|
<PolarAngleAxis dataKey="subject" tick={{ fontSize: 10 }} />
|
||||||
|
<PolarRadiusAxis
|
||||||
|
angle={90}
|
||||||
|
domain={[0, 100]}
|
||||||
|
tick={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<Radar
|
||||||
|
name="Profile"
|
||||||
|
dataKey="value"
|
||||||
|
stroke="#10b981"
|
||||||
|
fill="#10b981"
|
||||||
|
fillOpacity={0.3}
|
||||||
|
/>
|
||||||
|
</RadarChart>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">No data</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Monthly breakdown table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Monthly Breakdown</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-xs text-muted-foreground uppercase tracking-wide">
|
||||||
|
<th className="text-left px-4 py-2.5 font-medium">Month</th>
|
||||||
|
<th className="text-right px-3 py-2.5 font-medium">Hours</th>
|
||||||
|
<th className="text-right px-3 py-2.5 font-medium">Billable</th>
|
||||||
|
<th className="text-right px-3 py-2.5 font-medium">Bill %</th>
|
||||||
|
<th className="text-right px-3 py-2.5 font-medium hidden sm:table-cell">Days</th>
|
||||||
|
<th className="text-right px-3 py-2.5 font-medium hidden md:table-cell">Meetings</th>
|
||||||
|
<th className="text-right px-3 py-2.5 font-medium hidden md:table-cell">Messages</th>
|
||||||
|
<th className="text-right px-3 py-2.5 font-medium hidden lg:table-cell">Emails</th>
|
||||||
|
<th className="text-right px-3 py-2.5 font-medium hidden lg:table-cell">Calls</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{[...monthly].reverse().map(m => {
|
||||||
|
const pct = m.hoursWorked > 0 ? Math.round((m.billableHours / m.hoursWorked) * 100) : 0;
|
||||||
|
const isEmpty =
|
||||||
|
m.hoursWorked === 0 && m.teamsMessages === 0 && m.emailsSent === 0;
|
||||||
|
const totalCalls = m.zoomClientCalls + m.teamsCalls;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={m.month}
|
||||||
|
className={cn(
|
||||||
|
'border-b last:border-0 hover:bg-muted/30 transition-colors',
|
||||||
|
isEmpty && 'opacity-40'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2 font-medium">{monthLabel(m.month)}</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums">
|
||||||
|
{m.hoursWorked > 0 ? m.hoursWorked.toFixed(1) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums text-emerald-600 dark:text-emerald-400">
|
||||||
|
{m.billableHours > 0 ? m.billableHours.toFixed(1) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums">
|
||||||
|
{m.hoursWorked > 0 ? `${pct}%` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums hidden sm:table-cell">
|
||||||
|
{m.daysWorked > 0 ? m.daysWorked : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums hidden md:table-cell">
|
||||||
|
{m.totalMeetings > 0 ? m.totalMeetings : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums hidden md:table-cell">
|
||||||
|
{m.teamsMessages > 0 ? m.teamsMessages : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums hidden lg:table-cell">
|
||||||
|
{m.emailsSent > 0 ? m.emailsSent : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums hidden lg:table-cell">
|
||||||
|
{totalCalls > 0 ? totalCalls : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -51,18 +51,18 @@
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 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);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.97 0 0);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.97 0 0);
|
||||||
--muted-foreground: oklch(0.556 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);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.922 0 0);
|
||||||
--input: 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-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
|
@ -70,12 +70,12 @@
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.145 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-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.922 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 {
|
.dark {
|
||||||
|
|
@ -85,31 +85,31 @@
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.205 0 0);
|
||||||
--popover-foreground: oklch(0.985 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);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.269 0 0);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.269 0 0);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
--accent: oklch(0.75 0.15 162.48); /* Bright Teal for dark mode */
|
--accent: oklch(0.62 0.17 220); /* Logo blue */
|
||||||
--accent-foreground: oklch(0.145 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--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-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-3: oklch(0.769 0.188 70.08);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.205 0 0);
|
||||||
--sidebar-foreground: oklch(0.985 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-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--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 {
|
@layer base {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -12,7 +18,7 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { Search } from 'lucide-react';
|
import { Search, CheckCircle2, XCircle, Loader2, ExternalLink, Package } from 'lucide-react';
|
||||||
|
|
||||||
interface ComplianceMismatch {
|
interface ComplianceMismatch {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -25,15 +31,224 @@ interface ComplianceMismatch {
|
||||||
device_name: string;
|
device_name: string;
|
||||||
contract_name: string | null;
|
contract_name: string | null;
|
||||||
veeam_workload_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 {
|
interface ComplianceDetailTableProps {
|
||||||
mismatches: ComplianceMismatch[];
|
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) {
|
export function ComplianceDetailTable({ mismatches }: ComplianceDetailTableProps) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
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 filtered = mismatches.filter((m) => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
|
|
@ -82,7 +297,7 @@ export function ComplianceDetailTable({ mismatches }: ComplianceDetailTableProps
|
||||||
<TableHead>Device</TableHead>
|
<TableHead>Device</TableHead>
|
||||||
<TableHead>Issue</TableHead>
|
<TableHead>Issue</TableHead>
|
||||||
<TableHead>Backup UDF</TableHead>
|
<TableHead>Backup UDF</TableHead>
|
||||||
<TableHead>Contract</TableHead>
|
<TableHead>Contract Coverage</TableHead>
|
||||||
<TableHead>Veeam Workload</TableHead>
|
<TableHead>Veeam Workload</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -114,7 +329,34 @@ export function ComplianceDetailTable({ mismatches }: ComplianceDetailTableProps
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm">{m.backup_type_udf || '-'}</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>
|
<TableCell className="text-sm">{m.veeam_workload_name || '-'}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
|
|
@ -122,6 +364,13 @@ export function ComplianceDetailTable({ mismatches }: ComplianceDetailTableProps
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ContractCoverageModal
|
||||||
|
contractId={modalContractId}
|
||||||
|
companyName={modalCompanyName}
|
||||||
|
open={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
362
components/backup/contract-coverage-table.tsx
Normal file
362
components/backup/contract-coverage-table.tsx
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
Server,
|
||||||
|
Monitor,
|
||||||
|
Mail,
|
||||||
|
Package,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface ServiceLine {
|
||||||
|
cs_id: number;
|
||||||
|
contract_id: number;
|
||||||
|
contract_name: string;
|
||||||
|
line_name: string;
|
||||||
|
unit_price: number | null;
|
||||||
|
unit_cost: number | null;
|
||||||
|
category: 'server' | 'workstation' | 'm365' | 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CoverageRow {
|
||||||
|
company_id: number;
|
||||||
|
company_name: string;
|
||||||
|
contracted: { servers: number; workstations: number; m365: number; other: number };
|
||||||
|
deployed: { servers: number; workstations: number; other: number };
|
||||||
|
lines: ServiceLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
|
server: 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
|
||||||
|
workstation: 'bg-purple-500/10 text-purple-600 dark:text-purple-400',
|
||||||
|
m365: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
|
||||||
|
other: 'bg-muted text-muted-foreground',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
server: 'Server',
|
||||||
|
workstation: 'Workstation',
|
||||||
|
m365: 'M365',
|
||||||
|
other: 'Other',
|
||||||
|
};
|
||||||
|
|
||||||
|
function DeltaBadge({ contracted, deployed }: { contracted: number; deployed: number }) {
|
||||||
|
const delta = deployed - contracted;
|
||||||
|
if (contracted === 0 && deployed === 0) return <span className="text-muted-foreground text-xs">—</span>;
|
||||||
|
if (delta === 0) return <span className="text-xs text-green-600 dark:text-green-400 font-medium">✓</span>;
|
||||||
|
if (delta > 0)
|
||||||
|
return (
|
||||||
|
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||||
|
+{delta}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<span className="text-xs font-medium text-red-600 dark:text-red-400">
|
||||||
|
{delta}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CountCell({
|
||||||
|
contracted,
|
||||||
|
deployed,
|
||||||
|
}: {
|
||||||
|
contracted: number;
|
||||||
|
deployed: number;
|
||||||
|
}) {
|
||||||
|
const delta = deployed - contracted;
|
||||||
|
const hasData = contracted > 0 || deployed > 0;
|
||||||
|
if (!hasData) return <span className="text-muted-foreground text-xs">—</span>;
|
||||||
|
|
||||||
|
const color =
|
||||||
|
delta === 0
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: delta > 0
|
||||||
|
? 'text-amber-600 dark:text-amber-400'
|
||||||
|
: 'text-red-600 dark:text-red-400';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`text-sm font-medium tabular-nums ${color}`}>
|
||||||
|
{deployed}/{contracted}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClientRow({ row }: { row: CoverageRow }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const hasAnyData =
|
||||||
|
row.contracted.servers + row.contracted.workstations + row.contracted.m365 +
|
||||||
|
row.deployed.servers + row.deployed.workstations > 0;
|
||||||
|
|
||||||
|
// Group service lines by contract
|
||||||
|
const byContract = useMemo(() => {
|
||||||
|
const map = new Map<number, { contract_name: string; lines: ServiceLine[] }>();
|
||||||
|
for (const l of row.lines) {
|
||||||
|
if (!map.has(l.contract_id)) {
|
||||||
|
map.set(l.contract_id, { contract_name: l.contract_name, lines: [] });
|
||||||
|
}
|
||||||
|
map.get(l.contract_id)!.lines.push(l);
|
||||||
|
}
|
||||||
|
return [...map.values()];
|
||||||
|
}, [row.lines]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TableRow
|
||||||
|
className="cursor-pointer hover:bg-muted/40 group"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
{/* Expand toggle + Client */}
|
||||||
|
<TableCell className="font-medium py-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground group-hover:text-foreground transition-colors">
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="truncate max-w-[240px]">{row.company_name}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Servers deployed/contracted */}
|
||||||
|
<TableCell className="text-center py-2.5">
|
||||||
|
<CountCell contracted={row.contracted.servers} deployed={row.deployed.servers} />
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Workstations deployed/contracted */}
|
||||||
|
<TableCell className="text-center py-2.5">
|
||||||
|
<CountCell contracted={row.contracted.workstations} deployed={row.deployed.workstations} />
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* M365 contracted (no Veeam deployed count for M365) */}
|
||||||
|
<TableCell className="text-center py-2.5">
|
||||||
|
{row.contracted.m365 > 0 ? (
|
||||||
|
<span className="text-sm font-medium tabular-nums text-amber-600 dark:text-amber-400">
|
||||||
|
{row.contracted.m365}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-xs">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Total service lines */}
|
||||||
|
<TableCell className="text-center py-2.5">
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">{row.lines.length}</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
|
||||||
|
{/* Expanded detail rows */}
|
||||||
|
{expanded && (
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableCell colSpan={5} className="p-0 border-b">
|
||||||
|
<div className="bg-muted/20 px-4 py-3 space-y-3">
|
||||||
|
{byContract.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground py-1">No contract service lines found.</p>
|
||||||
|
) : (
|
||||||
|
byContract.map((contract) => {
|
||||||
|
const backupLines = contract.lines.filter(
|
||||||
|
(l) => l.category === 'server' || l.category === 'workstation'
|
||||||
|
);
|
||||||
|
if (backupLines.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={contract.contract_name} className="space-y-1.5">
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
{contract.contract_name}
|
||||||
|
</p>
|
||||||
|
<div className="rounded border overflow-hidden">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/40 border-b">
|
||||||
|
<th className="px-3 py-1.5 text-left font-medium">Service</th>
|
||||||
|
<th className="px-3 py-1.5 text-left font-medium w-28">Category</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{backupLines.map((line) => (
|
||||||
|
<tr key={line.cs_id} className="border-b last:border-0 hover:bg-muted/30">
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
{line.category === 'server' ? (
|
||||||
|
<Server className="inline h-3 w-3 mr-1.5 text-blue-500 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Monitor className="inline h-3 w-3 mr-1.5 text-purple-500 shrink-0" />
|
||||||
|
)}
|
||||||
|
{line.line_name}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<span
|
||||||
|
className={`inline-block px-1.5 py-0.5 rounded text-[11px] font-medium ${CATEGORY_COLORS[line.category]}`}
|
||||||
|
>
|
||||||
|
{CATEGORY_LABELS[line.category]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContractCoverageTable() {
|
||||||
|
const [rows, setRows] = useState<CoverageRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [filter, setFilter] = useState<'all' | 'gap' | 'over' | 'matched'>('all');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/veeam/contract-coverage')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => setRows(d.rows ?? []))
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return rows.filter((r) => {
|
||||||
|
const matchesSearch = r.company_name.toLowerCase().includes(search.toLowerCase());
|
||||||
|
if (!matchesSearch) return false;
|
||||||
|
if (filter === 'all') return true;
|
||||||
|
|
||||||
|
const serverDelta = r.deployed.servers - r.contracted.servers;
|
||||||
|
const wsDelta = r.deployed.workstations - r.contracted.workstations;
|
||||||
|
|
||||||
|
if (filter === 'gap') return serverDelta < 0 || wsDelta < 0;
|
||||||
|
if (filter === 'over') return serverDelta > 0 || wsDelta > 0;
|
||||||
|
if (filter === 'matched')
|
||||||
|
return (
|
||||||
|
r.contracted.servers > 0 || r.contracted.workstations > 0
|
||||||
|
? serverDelta === 0 && wsDelta === 0
|
||||||
|
: false
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [rows, search, filter]);
|
||||||
|
|
||||||
|
const FILTERS: { key: typeof filter; label: string }[] = [
|
||||||
|
{ key: 'all', label: 'All' },
|
||||||
|
{ key: 'gap', label: 'Under-deployed' },
|
||||||
|
{ key: 'over', label: 'Over-deployed' },
|
||||||
|
{ key: 'matched', label: 'Matched' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<div className="relative flex-1 max-w-xs">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search clients..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-9 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.key}
|
||||||
|
onClick={() => setFilter(f.key)}
|
||||||
|
className={`px-3 py-1 rounded text-xs font-medium transition-colors border ${
|
||||||
|
filter === f.key
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-transparent text-muted-foreground border-border hover:border-foreground/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground ml-auto">{filtered.length} clients</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium">Counts: deployed / contracted</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block w-2 h-2 rounded-full bg-green-500" /> Matched
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block w-2 h-2 rounded-full bg-amber-500" /> Over-deployed
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block w-2 h-2 rounded-full bg-red-500" /> Gap
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/40">
|
||||||
|
<TableHead className="text-xs">Client</TableHead>
|
||||||
|
<TableHead className="text-xs text-center w-28">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Server className="h-3 w-3" /> Servers
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-xs text-center w-28">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Monitor className="h-3 w-3" /> Workstations
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-xs text-center w-24">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Mail className="h-3 w-3" /> M365
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-xs text-center w-20">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Package className="h-3 w-3" /> Lines
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center text-muted-foreground py-12">
|
||||||
|
No clients match the current filter
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filtered.map((row) => <ClientRow key={row.company_id} row={row} />)
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,9 @@ import {
|
||||||
Zap,
|
Zap,
|
||||||
Radio,
|
Radio,
|
||||||
Shield,
|
Shield,
|
||||||
|
Users,
|
||||||
|
TrendingUp,
|
||||||
|
Sun,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
NavigationMenu,
|
NavigationMenu,
|
||||||
|
|
@ -61,6 +64,24 @@ const navigationItems: NavItem[] = [
|
||||||
icon: HardDrive,
|
icon: HardDrive,
|
||||||
description: 'Veeam backup health and compliance'
|
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',
|
title: 'Admin',
|
||||||
icon: Activity,
|
icon: Activity,
|
||||||
|
|
@ -119,6 +140,12 @@ const navigationItems: NavItem[] = [
|
||||||
icon: Zap,
|
icon: Zap,
|
||||||
description: 'Automated webhook processing workflows'
|
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',
|
title: 'Notification Channels',
|
||||||
href: '/admin/workflow/channels',
|
href: '/admin/workflow/channels',
|
||||||
|
|
@ -157,7 +184,7 @@ export function AppNavigation() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<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 */}
|
{/* Logo and App Name */}
|
||||||
<Link href="/" className="flex items-center space-x-3 shrink-0">
|
<Link href="/" className="flex items-center space-x-3 shrink-0">
|
||||||
<img
|
<img
|
||||||
|
|
@ -180,7 +207,7 @@ export function AppNavigation() {
|
||||||
<>
|
<>
|
||||||
<NavigationMenuTrigger className={cn(
|
<NavigationMenuTrigger className={cn(
|
||||||
"h-9 px-4 py-2",
|
"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.icon && <item.icon className="w-4 h-4 mr-2" />}
|
||||||
{item.title}
|
{item.title}
|
||||||
|
|
@ -193,8 +220,8 @@ export function AppNavigation() {
|
||||||
<Link
|
<Link
|
||||||
href={child.href || '#'}
|
href={child.href || '#'}
|
||||||
className={cn(
|
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",
|
"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-accent"
|
isActive(child.href) && "bg-primary text-primary-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center text-sm font-medium leading-none">
|
<div className="flex items-center text-sm font-medium leading-none">
|
||||||
|
|
@ -218,7 +245,7 @@ export function AppNavigation() {
|
||||||
<NavigationMenuLink className={cn(
|
<NavigationMenuLink className={cn(
|
||||||
navigationMenuTriggerStyle(),
|
navigationMenuTriggerStyle(),
|
||||||
"h-9",
|
"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.icon && <item.icon className="w-4 h-4 mr-2" />}
|
||||||
{item.title}
|
{item.title}
|
||||||
|
|
@ -255,7 +282,7 @@ interface PageHeaderProps {
|
||||||
export function PageHeader({ title, description, breadcrumbs, actions }: PageHeaderProps) {
|
export function PageHeader({ title, description, breadcrumbs, actions }: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="border-b">
|
<div className="border-b">
|
||||||
<div className="container px-6 py-4">
|
<div className="container mx-auto px-6 py-4">
|
||||||
{/* Breadcrumbs */}
|
{/* Breadcrumbs */}
|
||||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||||
<nav className="flex items-center space-x-2 text-sm text-muted-foreground mb-2">
|
<nav className="flex items-center space-x-2 text-sm text-muted-foreground mb-2">
|
||||||
|
|
|
||||||
713
components/zabbix/host-manager.tsx
Normal file
713
components/zabbix/host-manager.tsx
Normal file
|
|
@ -0,0 +1,713 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
RefreshCw,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
|
MinusCircle,
|
||||||
|
Search,
|
||||||
|
List,
|
||||||
|
Plus,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ZabbixTag { tag: string; value: string; }
|
||||||
|
interface ZabbixMacro { macro: string; value: string; description?: string; }
|
||||||
|
interface ZabbixGroup { groupid: string; name: string; }
|
||||||
|
interface ZabbixInterface { type: number; main: number; useip: number; ip: string; dns: string; port: string; }
|
||||||
|
|
||||||
|
interface ZabbixHostRow {
|
||||||
|
hostid: string;
|
||||||
|
host: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
description?: string;
|
||||||
|
interfaces?: ZabbixInterface[];
|
||||||
|
groups?: ZabbixGroup[];
|
||||||
|
macros?: ZabbixMacro[];
|
||||||
|
tags?: ZabbixTag[];
|
||||||
|
rmmMatched: boolean | null;
|
||||||
|
sourceTag: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Company {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HostManagerProps {
|
||||||
|
companies: Company[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function primaryIp(host: ZabbixHostRow): string {
|
||||||
|
const iface = host.interfaces?.find((i) => i.main === 1 || (i.main as any) === '1');
|
||||||
|
return iface?.ip ?? '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagValue(host: ZabbixHostRow, key: string): string | null {
|
||||||
|
return host.tags?.find((t) => t.tag === key)?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function macroValue(host: ZabbixHostRow, key: string): string | null {
|
||||||
|
return host.macros?.find((m) => m.macro === key)?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientLabel(host: ZabbixHostRow): string | null {
|
||||||
|
return tagValue(host, 'client') ?? macroValue(host, '{$AUTOTASK_COMPANY_NAME}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Edit Modal
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface EditModalProps {
|
||||||
|
host: ZabbixHostRow;
|
||||||
|
companies: Company[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (updated: ZabbixHostRow) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditModal({ host, companies, onClose, onSaved }: EditModalProps) {
|
||||||
|
const [name, setName] = useState(host.name);
|
||||||
|
const [ip, setIp] = useState(primaryIp(host));
|
||||||
|
const [description, setDescription] = useState(host.description ?? '');
|
||||||
|
const [companyId, setCompanyId] = useState<string>(() => {
|
||||||
|
const id = macroValue(host, '{$AUTOTASK_COMPANY_ID}');
|
||||||
|
return id ?? 'none';
|
||||||
|
});
|
||||||
|
const [tags, setTags] = useState<ZabbixTag[]>(() => host.tags ? [...host.tags] : []);
|
||||||
|
const [macros, setMacros] = useState<ZabbixMacro[]>(() => host.macros ? [...host.macros] : []);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [rebuildFromClient, setRebuildFromClient] = useState(false);
|
||||||
|
|
||||||
|
const ipv4Valid = /^(\d{1,3}\.){3}\d{1,3}$/.test(ip);
|
||||||
|
|
||||||
|
const addTag = () => setTags((t) => [...t, { tag: '', value: '' }]);
|
||||||
|
const removeTag = (i: number) => setTags((t) => t.filter((_, idx) => idx !== i));
|
||||||
|
const updateTag = (i: number, field: 'tag' | 'value', val: string) =>
|
||||||
|
setTags((t) => t.map((item, idx) => idx === i ? { ...item, [field]: val } : item));
|
||||||
|
|
||||||
|
const addMacro = () => setMacros((m) => [...m, { macro: '{$}', value: '', description: '' }]);
|
||||||
|
const removeMacro = (i: number) => setMacros((m) => m.filter((_, idx) => idx !== i));
|
||||||
|
const updateMacro = (i: number, field: keyof ZabbixMacro, val: string) =>
|
||||||
|
setMacros((m) => m.map((item, idx) => idx === i ? { ...item, [field]: val } : item));
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/zabbix/hosts/${host.hostid}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name.trim(),
|
||||||
|
ip: ip.trim(),
|
||||||
|
description,
|
||||||
|
companyId: companyId && companyId !== 'none' ? Number(companyId) : null,
|
||||||
|
tags,
|
||||||
|
macros,
|
||||||
|
rebuildFromClient,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
toast.error(data.error ?? 'Save failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(`Host "${name}" saved`);
|
||||||
|
// Return updated row (optimistic — caller will refresh)
|
||||||
|
onSaved({ ...host, name, description, tags, macros });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Save failed: ' + String(err));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Host</DialogTitle>
|
||||||
|
<DialogDescription className="font-mono text-xs">{host.hostid}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-5 py-2">
|
||||||
|
{/* Name + IP */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Display Name</Label>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>IP Address</Label>
|
||||||
|
<Input
|
||||||
|
value={ip}
|
||||||
|
onChange={(e) => setIp(e.target.value)}
|
||||||
|
className={`font-mono ${ip && !ipv4Valid ? 'border-destructive' : ''}`}
|
||||||
|
/>
|
||||||
|
{ip && !ipv4Valid && <p className="text-xs text-destructive">Invalid IPv4</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Client */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Client (Autotask)</Label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Select value={companyId} onValueChange={setCompanyId}>
|
||||||
|
<SelectTrigger className="w-72">
|
||||||
|
<SelectValue placeholder="No client" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">No client</SelectItem>
|
||||||
|
{companies.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rebuildFromClient}
|
||||||
|
onChange={(e) => setRebuildFromClient(e.target.checked)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
Rebuild macros/tags/groups from client
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Check "Rebuild" to re-run the full ISP lookup and regenerate all groups, macros, and tags
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Tags</Label>
|
||||||
|
<Button variant="ghost" size="sm" onClick={addTag} className="gap-1 h-7 text-xs">
|
||||||
|
<Plus className="w-3 h-3" /> Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{tags.map((t, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="tag"
|
||||||
|
value={t.tag}
|
||||||
|
onChange={(e) => updateTag(i, 'tag', e.target.value)}
|
||||||
|
className="w-36 text-sm h-8"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="value"
|
||||||
|
value={t.value}
|
||||||
|
onChange={(e) => updateTag(i, 'value', e.target.value)}
|
||||||
|
className="flex-1 text-sm h-8"
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => removeTag(i)}>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{tags.length === 0 && <p className="text-xs text-muted-foreground italic">No tags</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Macros */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Macros</Label>
|
||||||
|
<Button variant="ghost" size="sm" onClick={addMacro} className="gap-1 h-7 text-xs">
|
||||||
|
<Plus className="w-3 h-3" /> Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{macros.map((m, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="{$KEY}"
|
||||||
|
value={m.macro}
|
||||||
|
onChange={(e) => updateMacro(i, 'macro', e.target.value)}
|
||||||
|
className="w-52 font-mono text-sm h-8"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="value"
|
||||||
|
value={m.value}
|
||||||
|
onChange={(e) => updateMacro(i, 'value', e.target.value)}
|
||||||
|
className="flex-1 text-sm h-8"
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => removeMacro(i)}>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{macros.length === 0 && <p className="text-xs text-muted-foreground italic">No macros</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={saving}>Cancel</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving || !name.trim() || !ipv4Valid} className="gap-2">
|
||||||
|
{saving ? <><Loader2 className="w-4 h-4 animate-spin" /> Saving…</> : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete Confirm Dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface DeleteDialogProps {
|
||||||
|
count: number;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
deleting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteDialog({ count, onConfirm, onCancel, deleting }: DeleteDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => { if (!o) onCancel(); }}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete {count} host{count !== 1 ? 's' : ''}?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will permanently remove {count === 1 ? 'this host' : `these ${count} hosts`} from Zabbix. This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onCancel} disabled={deleting}>Cancel</Button>
|
||||||
|
<Button variant="destructive" onClick={onConfirm} disabled={deleting} className="gap-2">
|
||||||
|
{deleting ? <><Loader2 className="w-4 h-4 animate-spin" /> Deleting…</> : <><Trash2 className="w-4 h-4" /> Delete</>}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main HostManager component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function HostManager({ companies }: HostManagerProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [hosts, setHosts] = useState<ZabbixHostRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [filterSource, setFilterSource] = useState<'all' | 'datto-rmm' | 'manual'>('all');
|
||||||
|
const [filterRmm, setFilterRmm] = useState<'all' | 'matched' | 'unmatched'>('all');
|
||||||
|
const [filterClient, setFilterClient] = useState<string>('all');
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Edit / delete
|
||||||
|
const [editHost, setEditHost] = useState<ZabbixHostRow | null>(null);
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const loadHosts = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/zabbix/hosts');
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) throw new Error(data.error ?? 'Failed to load');
|
||||||
|
setHosts(data.hosts ?? []);
|
||||||
|
setLoaded(true);
|
||||||
|
setSelected(new Set());
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Failed to load hosts: ' + String(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
setOpen(true);
|
||||||
|
if (!loaded) loadHosts();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filtered hosts
|
||||||
|
const filtered = hosts.filter((h) => {
|
||||||
|
if (search) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
const ip = primaryIp(h).toLowerCase();
|
||||||
|
if (
|
||||||
|
!h.name.toLowerCase().includes(q) &&
|
||||||
|
!ip.includes(q) &&
|
||||||
|
!(clientLabel(h) ?? '').toLowerCase().includes(q)
|
||||||
|
) return false;
|
||||||
|
}
|
||||||
|
if (filterSource !== 'all' && h.sourceTag !== filterSource) return false;
|
||||||
|
if (filterRmm === 'matched' && h.rmmMatched !== true) return false;
|
||||||
|
if (filterRmm === 'unmatched' && h.rmmMatched !== false) return false;
|
||||||
|
if (filterClient !== 'all') {
|
||||||
|
const cl = clientLabel(h);
|
||||||
|
if (!cl || !cl.toLowerCase().includes(filterClient.toLowerCase())) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// All-select toggle
|
||||||
|
const allSelected = filtered.length > 0 && filtered.every((h) => selected.has(h.hostid));
|
||||||
|
const someSelected = filtered.some((h) => selected.has(h.hostid));
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
if (allSelected) {
|
||||||
|
setSelected((s) => { const n = new Set(s); filtered.forEach((h) => n.delete(h.hostid)); return n; });
|
||||||
|
} else {
|
||||||
|
setSelected((s) => { const n = new Set(s); filtered.forEach((h) => n.add(h.hostid)); return n; });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleOne = (id: string) => {
|
||||||
|
setSelected((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedCount = selected.size;
|
||||||
|
|
||||||
|
// Bulk delete
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const hostids = Array.from(selected);
|
||||||
|
const resp = await fetch('/api/zabbix/hosts', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ hostids }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) throw new Error(data.error ?? 'Delete failed');
|
||||||
|
toast.success(`Deleted ${data.deleted} host${data.deleted !== 1 ? 's' : ''}`);
|
||||||
|
setHosts((h) => h.filter((host) => !selected.has(host.hostid)));
|
||||||
|
setSelected(new Set());
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Delete failed: ' + String(err));
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unique client names for filter dropdown
|
||||||
|
const clientNames = Array.from(
|
||||||
|
new Set(hosts.map((h) => clientLabel(h)).filter(Boolean) as string[])
|
||||||
|
).sort();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
className="pb-4 cursor-pointer select-none"
|
||||||
|
onClick={() => (open ? setOpen(false) : handleOpen())}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{open ? <ChevronDown className="w-4 h-4 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 text-muted-foreground" />}
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
<CardTitle className="text-base">Host Manager</CardTitle>
|
||||||
|
{loaded && (
|
||||||
|
<Badge variant="secondary" className="text-xs">{hosts.length}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardDescription className="mt-0">
|
||||||
|
Browse, filter, edit and delete existing Zabbix hosts
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<CardContent className="pt-0 space-y-4">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search name, IP, client…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-8 w-56 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source filter */}
|
||||||
|
<Select value={filterSource} onValueChange={(v) => setFilterSource(v as any)}>
|
||||||
|
<SelectTrigger className="w-36 h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All sources</SelectItem>
|
||||||
|
<SelectItem value="datto-rmm">datto-rmm</SelectItem>
|
||||||
|
<SelectItem value="manual">manual</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* RMM match filter */}
|
||||||
|
<Select value={filterRmm} onValueChange={(v) => setFilterRmm(v as any)}>
|
||||||
|
<SelectTrigger className="w-40 h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All RMM status</SelectItem>
|
||||||
|
<SelectItem value="matched">RMM matched</SelectItem>
|
||||||
|
<SelectItem value="unmatched">RMM unmatched</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Client filter */}
|
||||||
|
<Select value={filterClient} onValueChange={setFilterClient}>
|
||||||
|
<SelectTrigger className="w-48 h-8 text-sm">
|
||||||
|
<SelectValue placeholder="All clients" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All clients</SelectItem>
|
||||||
|
{clientNames.map((c) => (
|
||||||
|
<SelectItem key={c} value={c}>{c}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{/* Bulk delete toolbar */}
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md bg-destructive/5 border border-destructive/20">
|
||||||
|
<span className="text-sm font-medium text-destructive">{selectedCount} selected</span>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1.5"
|
||||||
|
onClick={() => setShowDeleteDialog(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" /> Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm" onClick={loadHosts} disabled={loading} className="gap-1.5 h-8">
|
||||||
|
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Result count */}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{loading ? 'Loading…' : `${filtered.length} of ${hosts.length} host${hosts.length !== 1 ? 's' : ''}`}
|
||||||
|
{filterRmm === 'unmatched' && !loading && (
|
||||||
|
<span className="ml-2 text-amber-600 font-medium">— {filtered.length} not matched to an RMM site</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="rounded-md border overflow-hidden">
|
||||||
|
<div className="max-h-[560px] overflow-y-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-10">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
ref={(el) => { if (el) (el as any).indeterminate = someSelected && !allSelected; }}
|
||||||
|
onCheckedChange={toggleAll}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>IP</TableHead>
|
||||||
|
<TableHead>Client</TableHead>
|
||||||
|
<TableHead>ISP</TableHead>
|
||||||
|
<TableHead>Source</TableHead>
|
||||||
|
<TableHead>RMM</TableHead>
|
||||||
|
<TableHead>Groups</TableHead>
|
||||||
|
<TableHead className="w-10" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} className="text-center py-12 text-muted-foreground">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
||||||
|
Loading hosts…
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{!loading && filtered.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} className="text-center py-12 text-muted-foreground text-sm">
|
||||||
|
No hosts match the current filters.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{!loading && filtered.map((h) => {
|
||||||
|
const isUnmatched = h.rmmMatched === false;
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={h.hostid}
|
||||||
|
className={`${selected.has(h.hostid) ? 'bg-muted/40' : ''} ${isUnmatched ? 'border-l-2 border-l-amber-400 bg-amber-500/5' : ''}`}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={selected.has(h.hostid)}
|
||||||
|
onCheckedChange={() => toggleOne(h.hostid)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium text-sm max-w-[200px]">
|
||||||
|
<div className="truncate" title={h.name}>{h.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-mono truncate">{h.host !== h.name ? h.host : ''}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{primaryIp(h)}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground max-w-[160px]">
|
||||||
|
<span className="truncate block" title={clientLabel(h) ?? undefined}>
|
||||||
|
{clientLabel(h) ?? <span className="italic opacity-50">—</span>}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm max-w-[160px]">
|
||||||
|
<div className="truncate" title={tagValue(h, 'isp') ?? undefined}>
|
||||||
|
{tagValue(h, 'isp') ?? <span className="text-muted-foreground">—</span>}
|
||||||
|
</div>
|
||||||
|
{tagValue(h, 'asn') && (
|
||||||
|
<div className="text-xs text-muted-foreground">{tagValue(h, 'asn')}</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{h.sourceTag ? (
|
||||||
|
<Badge variant={h.sourceTag === 'datto-rmm' ? 'secondary' : 'outline'} className="text-xs">
|
||||||
|
{h.sourceTag}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-xs">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{h.rmmMatched === true && (
|
||||||
|
<span title="Matched to an RMM site">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{h.rmmMatched === false && (
|
||||||
|
<span title="No matching RMM site found">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{h.rmmMatched === null && (
|
||||||
|
<span title="Not an RMM-sourced host">
|
||||||
|
<MinusCircle className="w-4 h-4 text-muted-foreground/40" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[180px]">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(h.groups ?? []).slice(0, 3).map((g) => (
|
||||||
|
<Badge key={g.groupid} variant="outline" className="text-xs px-1.5 py-0">
|
||||||
|
{g.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{(h.groups?.length ?? 0) > 3 && (
|
||||||
|
<Badge variant="outline" className="text-xs px-1.5 py-0">
|
||||||
|
+{(h.groups?.length ?? 0) - 3}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => setEditHost(h)}
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Edit modal */}
|
||||||
|
{editHost && (
|
||||||
|
<EditModal
|
||||||
|
host={editHost}
|
||||||
|
companies={companies}
|
||||||
|
onClose={() => setEditHost(null)}
|
||||||
|
onSaved={(updated) => {
|
||||||
|
setHosts((hs) => hs.map((h) => h.hostid === updated.hostid ? updated : h));
|
||||||
|
setEditHost(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirm */}
|
||||||
|
{showDeleteDialog && (
|
||||||
|
<DeleteDialog
|
||||||
|
count={selectedCount}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onCancel={() => setShowDeleteDialog(false)}
|
||||||
|
deleting={deleting}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
435
dev/pulse-morning-summary-architecture.md
Normal file
435
dev/pulse-morning-summary-architecture.md
Normal file
|
|
@ -0,0 +1,435 @@
|
||||||
|
# Architecture: Pulse Morning NOC Summary
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Wulf Consulting (MSP) needs a daily morning summary sent to management showing overnight activity
|
||||||
|
and open issues across client infrastructure. Pulse (Node.js/TypeScript) is the internal web app
|
||||||
|
with existing API integrations to Zabbix, PSA, RMM, Veeam, Zoom, and MS Graph. It already has a
|
||||||
|
job scheduler (bull/agenda/node-cron). Apprise is running on the monitoring server for notification routing.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ schedule ┌──────────────────────┐
|
||||||
|
│ Job Scheduler├─────────────►│ Summary Aggregator │
|
||||||
|
│ (existing) │ 6:30 AM │ Service │
|
||||||
|
└──────────────┘ └──────┬───────────────┘
|
||||||
|
│ parallel queries
|
||||||
|
┌─────────────────┼─────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||||
|
│ Zabbix │ │ PSA │ │ Veeam │
|
||||||
|
│ API │ │ API │ │ API │
|
||||||
|
└────┬─────┘ └────┬─────┘ └────┬─────┘
|
||||||
|
│ │ │
|
||||||
|
└───────┬───────┘ │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────┐ │
|
||||||
|
│ Data Correlator │◄──────────────┘
|
||||||
|
│ & Formatter │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌───────────┼───────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌──────────┐ ┌────────┐ ┌─────────┐
|
||||||
|
│ Teams │ │ Apprise│ │ Pulse │
|
||||||
|
│ MS Graph│ │ (ntfy, │ │ DB + │
|
||||||
|
│ Adaptive│ │ email)│ │ Widget │
|
||||||
|
│ Card │ │ │ │ │
|
||||||
|
└──────────┘ └────────┘ └─────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hybrid Notification Strategy
|
||||||
|
|
||||||
|
| Channel | Method | Why |
|
||||||
|
|---------|--------|-----|
|
||||||
|
| **Teams Adaptive Card** | Direct via MS Graph | Rich formatting, action buttons, inline rendering — can't get this through Apprise |
|
||||||
|
| **Email (HTML)** | Via Apprise | Apprise handles SMTP config, templating is simpler for email |
|
||||||
|
| **ntfy** | Via Apprise | Already configured, Apprise knows the topic/auth |
|
||||||
|
| **Pulse Dashboard** | Direct DB write | Store the summary as a record, render as a widget |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### 1. Summary Aggregator Service
|
||||||
|
|
||||||
|
Single service class/module: `MorningSummaryService`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface MorningSummary {
|
||||||
|
generatedAt: Date;
|
||||||
|
reportWindow: { from: Date; to: Date }; // e.g. 6pm → 6:30am
|
||||||
|
|
||||||
|
openProblems: Problem[]; // currently active — most important section
|
||||||
|
resolvedOvernight: Problem[]; // resolved during the window
|
||||||
|
backupFailures: BackupJob[]; // from Veeam API
|
||||||
|
unmatchedAlerts: Problem[]; // Zabbix problems with no PSA ticket (action needed!)
|
||||||
|
|
||||||
|
stats: {
|
||||||
|
totalIncidents: number;
|
||||||
|
resolved: number;
|
||||||
|
stillOpen: number;
|
||||||
|
mttrMinutes: number; // mean time to resolve (overnight only)
|
||||||
|
clientsAffected: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Problem {
|
||||||
|
host: string;
|
||||||
|
client: string; // from Zabbix host group "Clients/..."
|
||||||
|
triggerName: string; // "Host Unreachable", "High Packet Loss", etc.
|
||||||
|
severity: string;
|
||||||
|
startedAt: Date;
|
||||||
|
resolvedAt?: Date;
|
||||||
|
duration: string;
|
||||||
|
psaTicketId?: string; // correlated from PSA
|
||||||
|
psaTicketUrl?: string;
|
||||||
|
acknowledged: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupJob {
|
||||||
|
client: string;
|
||||||
|
server: string;
|
||||||
|
jobName: string;
|
||||||
|
status: string;
|
||||||
|
lastRun: Date;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Zabbix API Queries Needed
|
||||||
|
|
||||||
|
**Open problems:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "problem.get",
|
||||||
|
"params": {
|
||||||
|
"recent": true,
|
||||||
|
"sortfield": ["eventid"],
|
||||||
|
"sortorder": "DESC"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resolved overnight:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "event.get",
|
||||||
|
"params": {
|
||||||
|
"source": 0,
|
||||||
|
"object": 0,
|
||||||
|
"value": 0,
|
||||||
|
"time_from": "<6pm_yesterday_unix>",
|
||||||
|
"time_to": "<6:30am_today_unix>",
|
||||||
|
"selectHosts": ["name"],
|
||||||
|
"selectRelatedObject": ["description"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Host → Client mapping:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "host.get",
|
||||||
|
"params": {
|
||||||
|
"selectHostGroups": ["name"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Filter groups starting with `Clients/` to determine the client name per host.
|
||||||
|
|
||||||
|
**Host groups available for reference:**
|
||||||
|
- `Clients/Kuhn's Quality Foods`, `Clients/ADM Signs`, `Clients/Seubert and Associates`, etc. (50+ clients)
|
||||||
|
- `ISP/Comcast Cable Communications, LLC`, `ISP/AT&T Enterprises, LLC`, etc. (25+ ISPs)
|
||||||
|
|
||||||
|
### 3. PSA Correlation
|
||||||
|
|
||||||
|
For each open Zabbix problem, query the PSA for matching tickets:
|
||||||
|
- Match on hostname or client name + date range
|
||||||
|
- Flag any Zabbix problem that has NO corresponding PSA ticket — these are "unmatched"
|
||||||
|
and should be highlighted as needing attention
|
||||||
|
|
||||||
|
### 4. Veeam Backup Failures
|
||||||
|
|
||||||
|
Query Veeam API for jobs that ran overnight with status != Success.
|
||||||
|
Include in a separate section of the summary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teams Adaptive Card Design
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "AdaptiveCard",
|
||||||
|
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||||
|
"version": "1.4",
|
||||||
|
"body": [
|
||||||
|
{
|
||||||
|
"type": "TextBlock",
|
||||||
|
"text": "☀️ Morning NOC Summary — Mar 12, 2026",
|
||||||
|
"weight": "bolder",
|
||||||
|
"size": "large"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "ColumnSet",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"type": "Column",
|
||||||
|
"width": "auto",
|
||||||
|
"items": [
|
||||||
|
{ "type": "TextBlock", "text": "3", "size": "extraLarge", "color": "attention", "weight": "bolder" },
|
||||||
|
{ "type": "TextBlock", "text": "Open", "spacing": "none" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Column",
|
||||||
|
"width": "auto",
|
||||||
|
"items": [
|
||||||
|
{ "type": "TextBlock", "text": "5", "size": "extraLarge", "color": "good", "weight": "bolder" },
|
||||||
|
{ "type": "TextBlock", "text": "Resolved", "spacing": "none" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Column",
|
||||||
|
"width": "auto",
|
||||||
|
"items": [
|
||||||
|
{ "type": "TextBlock", "text": "18m", "size": "extraLarge", "weight": "bolder" },
|
||||||
|
{ "type": "TextBlock", "text": "Avg MTTR", "spacing": "none" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Container",
|
||||||
|
"style": "attention",
|
||||||
|
"bleed": true,
|
||||||
|
"items": [
|
||||||
|
{ "type": "TextBlock", "text": "🔴 OPEN ISSUES", "weight": "bolder", "spacing": "small" },
|
||||||
|
{
|
||||||
|
"type": "FactSet",
|
||||||
|
"facts": [
|
||||||
|
{ "title": "Kuhn's / FW-01", "value": "Host Unreachable — 4h 12m" },
|
||||||
|
{ "title": "ADM / SW-Core", "value": "High Packet Loss — 2h 5m" },
|
||||||
|
{ "title": "Seubert / DC-01", "value": "⚠️ Backup Failed — 6h (no ticket!)" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Container",
|
||||||
|
"style": "good",
|
||||||
|
"bleed": true,
|
||||||
|
"items": [
|
||||||
|
{ "type": "TextBlock", "text": "🟢 RESOLVED OVERNIGHT", "weight": "bolder", "spacing": "small" },
|
||||||
|
{
|
||||||
|
"type": "FactSet",
|
||||||
|
"facts": [
|
||||||
|
{ "title": "LWHResTest", "value": "Host Unreachable — resolved in 15m" },
|
||||||
|
{ "title": "Brodaks / RTR-01", "value": "Slow Response — resolved in 22m" },
|
||||||
|
{ "title": "+3 more", "value": "All auto-resolved" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{ "type": "Action.OpenUrl", "title": "Open Zabbix", "url": "https://zabbix.wulfconsulting.cloud" },
|
||||||
|
{ "type": "Action.OpenUrl", "title": "Open Pulse", "url": "https://pulse.wulfconsulting.cloud" },
|
||||||
|
{ "type": "Action.OpenUrl", "title": "Ack All Open", "url": "https://pulse.wulfconsulting.cloud/ack-all" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Post via MS Graph:
|
||||||
|
```
|
||||||
|
POST https://graph.microsoft.com/v1.0/teams/{teamId}/channels/{channelId}/messages
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"body": {
|
||||||
|
"contentType": "html",
|
||||||
|
"content": "<attachment id=\"card\"></attachment>"
|
||||||
|
},
|
||||||
|
"attachments": [{
|
||||||
|
"id": "card",
|
||||||
|
"contentType": "application/vnd.microsoft.card.adaptive",
|
||||||
|
"content": "<adaptive card JSON string>"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Preference System
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface NotificationPreferences {
|
||||||
|
userId: string;
|
||||||
|
morningSummary: {
|
||||||
|
enabled: boolean;
|
||||||
|
channels: ('teams' | 'email' | 'ntfy' | 'pulse')[];
|
||||||
|
schedule: string; // cron expression, default "30 6 * * 1-5"
|
||||||
|
timezone: string; // "America/New_York"
|
||||||
|
includeBackups: boolean;
|
||||||
|
includeResolvedDetail: boolean; // some execs just want open issues
|
||||||
|
severityFilter: number; // minimum severity to include (default: 2/Warning)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Store in Pulse's existing user/settings table. Expose in Pulse UI as a settings page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Job Scheduler Integration
|
||||||
|
|
||||||
|
Use the existing scheduler to run the aggregation:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Register the job
|
||||||
|
scheduler.register('morning-summary', '30 6 * * 1-5', async () => {
|
||||||
|
const users = await getUsersWithMorningSummaryEnabled();
|
||||||
|
|
||||||
|
// Aggregate once (shared data)
|
||||||
|
const summary = await morningSummaryService.aggregate();
|
||||||
|
|
||||||
|
// Store for Pulse dashboard widget
|
||||||
|
await morningSummaryService.persist(summary);
|
||||||
|
|
||||||
|
// Deliver per user preferences
|
||||||
|
for (const user of users) {
|
||||||
|
const prefs = user.notificationPreferences.morningSummary;
|
||||||
|
|
||||||
|
if (prefs.channels.includes('teams'))
|
||||||
|
await teamsService.postAdaptiveCard(user, summary);
|
||||||
|
|
||||||
|
if (prefs.channels.includes('email'))
|
||||||
|
await appriseService.sendEmail(user, formatEmailHtml(summary));
|
||||||
|
|
||||||
|
if (prefs.channels.includes('ntfy'))
|
||||||
|
await appriseService.sendNtfy(formatNtfySummary(summary));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Aggregator Pseudocode
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class MorningSummaryService {
|
||||||
|
async aggregate(): Promise<MorningSummary> {
|
||||||
|
const now = new Date();
|
||||||
|
const windowStart = yesterday6pm(now);
|
||||||
|
const windowEnd = now;
|
||||||
|
|
||||||
|
// Run all API calls in parallel
|
||||||
|
const [zabbixOpen, zabbixResolved, backups, hostGroups] = await Promise.all([
|
||||||
|
this.zabbixApi.getOpenProblems(),
|
||||||
|
this.zabbixApi.getResolvedEvents(windowStart, windowEnd),
|
||||||
|
this.veeamApi.getOvernightJobs(windowStart, windowEnd),
|
||||||
|
this.zabbixApi.getHostGroupMapping() // cache this, changes rarely
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Map hosts → client names via host groups
|
||||||
|
const clientMap = buildClientMap(hostGroups);
|
||||||
|
|
||||||
|
// Correlate with PSA tickets
|
||||||
|
const openWithTickets = await this.psaApi.correlateProblems(zabbixOpen, clientMap);
|
||||||
|
|
||||||
|
// Find unmatched (no PSA ticket)
|
||||||
|
const unmatched = openWithTickets.filter(p => !p.psaTicketId);
|
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const mttr = calculateMTTR(zabbixResolved);
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatedAt: now,
|
||||||
|
reportWindow: { from: windowStart, to: windowEnd },
|
||||||
|
openProblems: openWithTickets,
|
||||||
|
resolvedOvernight: zabbixResolved.map(e => enrichWithClient(e, clientMap)),
|
||||||
|
backupFailures: backups.filter(b => b.status !== 'Success'),
|
||||||
|
unmatchedAlerts: unmatched,
|
||||||
|
stats: {
|
||||||
|
totalIncidents: zabbixOpen.length + zabbixResolved.length,
|
||||||
|
resolved: zabbixResolved.length,
|
||||||
|
stillOpen: zabbixOpen.length,
|
||||||
|
mttrMinutes: mttr,
|
||||||
|
clientsAffected: [...new Set(openWithTickets.map(p => p.client))]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Recommendations
|
||||||
|
|
||||||
|
1. **Aggregate once, deliver many** — Don't re-query Zabbix/PSA/Veeam per user. Run the
|
||||||
|
aggregation once, then fan out to each user's preferred channels.
|
||||||
|
|
||||||
|
2. **"Unmatched alerts" section is the killer feature** — Highlighting Zabbix problems that
|
||||||
|
have no PSA ticket is what will make management love this. It shows gaps in the process.
|
||||||
|
|
||||||
|
3. **Teams adaptive card direct, everything else through Apprise** — Adaptive cards need
|
||||||
|
the MS Graph payload format which Apprise can't produce. For simpler formats (email body,
|
||||||
|
ntfy text), Apprise handles routing without Pulse needing SMTP config.
|
||||||
|
|
||||||
|
4. **Pulse dashboard widget** — Persist each summary to the DB. Show the latest on the
|
||||||
|
Pulse home screen so anyone can check it anytime, not just at 6:30 AM.
|
||||||
|
|
||||||
|
5. **Weekend mode** — Consider a different schedule or suppression for weekends. The cron
|
||||||
|
`30 6 * * 1-5` only fires Mon-Fri. But you may want a Monday morning summary that covers
|
||||||
|
the full weekend window (Friday 6pm → Monday 6:30am).
|
||||||
|
|
||||||
|
6. **Escalation hint** — If any problem has been open > 4 hours with no PSA ticket and no
|
||||||
|
acknowledgement, flag it red in the card with "Needs Attention" — gives management
|
||||||
|
actionable signal, not just data.
|
||||||
|
|
||||||
|
7. **Cache the host → client mapping** — The `Clients/` host group mapping rarely changes.
|
||||||
|
Cache it in Pulse (refresh every few hours) to avoid an API call on every summary run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zabbix Host Group Reference
|
||||||
|
|
||||||
|
Your host groups are well-organized for this feature:
|
||||||
|
|
||||||
|
**Client groups (50+):** `Clients/Kuhn's Quality Foods`, `Clients/ADM Signs`, `Clients/Seubert and Associates`, `Clients/Brodaks`, etc.
|
||||||
|
|
||||||
|
**ISP groups (25+):** `ISP/Comcast`, `ISP/AT&T`, `ISP/Armstrong`, `ISP/Bigleaf`, etc.
|
||||||
|
|
||||||
|
The ISP grouping can be used for a future enhancement: "ISP Outage Detection" — if 3+ hosts
|
||||||
|
on the same ISP go down simultaneously, flag it as a likely ISP outage rather than individual
|
||||||
|
site problems.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ntfy Summary Format (via Apprise)
|
||||||
|
|
||||||
|
For the condensed ntfy version:
|
||||||
|
|
||||||
|
```
|
||||||
|
☀️ Morning Summary — Mar 12
|
||||||
|
|
||||||
|
🔴 3 Open
|
||||||
|
· Host Unreachable — Kuhn's / FW-01 (4h)
|
||||||
|
· High Packet Loss — ADM / SW-Core (2h)
|
||||||
|
· Backup Failed — Seubert / DC-01 (6h)
|
||||||
|
|
||||||
|
🟢 5 Resolved overnight (avg 18m)
|
||||||
|
|
||||||
|
⚠️ 1 issue with no ticket
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Email HTML Format (via Apprise)
|
||||||
|
|
||||||
|
Use a clean responsive HTML template with:
|
||||||
|
- Header with date and stats (open/resolved/MTTR)
|
||||||
|
- Red-bordered table for open issues
|
||||||
|
- Green-bordered table for resolved
|
||||||
|
- Yellow callout box for unmatched alerts
|
||||||
|
- Footer with links to Zabbix and Pulse
|
||||||
|
|
||||||
|
Keep it mobile-friendly — management reads email on phones.
|
||||||
774
dev/windsurf-zabbix-development-guide.md
Normal file
774
dev/windsurf-zabbix-development-guide.md
Normal file
|
|
@ -0,0 +1,774 @@
|
||||||
|
# Windsurf + Sonnet Development Guide — Zabbix Monitoring System
|
||||||
|
|
||||||
|
> **For:** AI-assisted development in Windsurf using Claude Sonnet 4.6
|
||||||
|
> **Organization:** Wulf Consulting (MSP)
|
||||||
|
> **Last updated:** 2026-03-11
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [System Overview](#1-system-overview)
|
||||||
|
2. [Infrastructure & Network Topology](#2-infrastructure--network-topology)
|
||||||
|
3. [Zabbix API Reference](#3-zabbix-api-reference)
|
||||||
|
4. [Host Organization & Data Model](#4-host-organization--data-model)
|
||||||
|
5. [Notification Pipeline](#5-notification-pipeline)
|
||||||
|
6. [Trigger Naming Conventions](#6-trigger-naming-conventions)
|
||||||
|
7. [Grafana Integration](#7-grafana-integration)
|
||||||
|
8. [Pulse Integration Architecture](#8-pulse-integration-architecture)
|
||||||
|
9. [Development Patterns & Gotchas](#9-development-patterns--gotchas)
|
||||||
|
10. [API Cookbook](#10-api-cookbook)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. System Overview
|
||||||
|
|
||||||
|
Wulf Consulting is an MSP managing ~46 client sites. The monitoring stack runs on a single
|
||||||
|
Ubuntu 24.04 server (public IP: 209.166.162.245) with all services containerized in Docker.
|
||||||
|
|
||||||
|
### Stack Components
|
||||||
|
|
||||||
|
| Service | Container | Image | Purpose |
|
||||||
|
|---------|-----------|-------|---------|
|
||||||
|
| **Zabbix Server** | `zabbix-server` | `zabbix/zabbix-server-pgsql:alpine-7.4-latest` | Core monitoring engine |
|
||||||
|
| **Zabbix Frontend** | `zabbix-frontend` | `zabbix/zabbix-web-nginx-pgsql:alpine-7.4-latest` | Web UI + API endpoint |
|
||||||
|
| **PostgreSQL** | `zabbix-postgres` | `postgres:17-alpine` | Zabbix database |
|
||||||
|
| **Grafana** | `grafana` | `grafana/grafana:latest` | Dashboards + Zabbix plugin |
|
||||||
|
| **ntfy** | `ntfy` | `binwiederhier/ntfy:latest` | Push notification server |
|
||||||
|
| **Apprise** | `apprise-api` | `caronc/apprise:latest` | Multi-channel notification router |
|
||||||
|
| **Authentik** | `authentik` | `ghcr.io/goauthentik/server:2025.8.1` | SSO/identity provider |
|
||||||
|
| **Newt** | `newt` | `fosrl/newt` | Pangolin tunnel agent |
|
||||||
|
|
||||||
|
### External Access
|
||||||
|
|
||||||
|
All services are exposed through **Pangolin** (reverse proxy/tunnel), not direct port mappings.
|
||||||
|
|
||||||
|
| Service | External URL |
|
||||||
|
|---------|-------------|
|
||||||
|
| Zabbix | `https://zabbix.wulfconsulting.cloud` |
|
||||||
|
| Grafana | *(via Pangolin — check Pangolin config for exact URL)* |
|
||||||
|
|
||||||
|
SSO is handled by Authentik with SAML integration to Zabbix.
|
||||||
|
|
||||||
|
### Software Versions
|
||||||
|
|
||||||
|
- **Zabbix:** 7.4.7
|
||||||
|
- **PostgreSQL:** 17 (Alpine)
|
||||||
|
- **Grafana:** Latest (with `alexanderzobnin-zabbix-app` 6.2.1)
|
||||||
|
- **Host OS:** Ubuntu 24.04.4 LTS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Infrastructure & Network Topology
|
||||||
|
|
||||||
|
### Docker Networks
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ pangolin (172.18.0.0/16) │
|
||||||
|
│ ┌─────────────┐ ┌──────────┐ ┌──────┐ ┌───────────────┐ │
|
||||||
|
│ │zabbix-front │ │ grafana │ │ ntfy │ │ apprise-api │ │
|
||||||
|
│ │ 172.18.0.4 │ │172.18.0.3│ │.0.7 │ │ 172.18.0.5 │ │
|
||||||
|
│ └──────┬──────┘ └────┬─────┘ └──────┘ └───────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌──────┴──────┐ │ ┌──────────┐ ┌───────────┐ │
|
||||||
|
│ │zabbix-server│ │ │authentik │ │ newt │ │
|
||||||
|
│ │ 172.18.0.8 │ │ │172.18.0.6│ │172.18.0.2 │ │
|
||||||
|
│ └──────┬──────┘ │ └──────────┘ └───────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
└─────────┼──────────────┼─────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
┌─────────┼──────────────┼──────────────────────┐
|
||||||
|
│ │ zabbix_zabbix_internal (172.19.0.0/16) │
|
||||||
|
│ ┌──────┴──────┐ ┌────┴─────┐ ┌──────────────┐│
|
||||||
|
│ │zabbix-server│ │ grafana │ │zabbix-frontend││
|
||||||
|
│ │ 172.19.0.4 │ │172.19.0.3│ │ 172.19.0.5 ││
|
||||||
|
│ └─────────────┘ └──────────┘ └──────────────┘│
|
||||||
|
│ ┌──────────────┐│
|
||||||
|
│ │zabbix-postgres││
|
||||||
|
│ │ 172.19.0.2 ││
|
||||||
|
│ └──────────────┘│
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Connectivity Facts
|
||||||
|
|
||||||
|
- **Zabbix API (internal):** `http://zabbix-frontend:8080/api_jsonrpc.php` — accessible from `zabbix-server`, `grafana`, and anything on `zabbix_zabbix_internal`
|
||||||
|
- **Zabbix API (external):** `https://zabbix.wulfconsulting.cloud/api_jsonrpc.php` — via Pangolin
|
||||||
|
- **ntfy (internal):** `http://ntfy:80` — accessible from `zabbix-server` and `zabbix-frontend` via `pangolin` network
|
||||||
|
- **Apprise API:** `http://apprise-api:8000` (also `0.0.0.0:8000` on host)
|
||||||
|
- **Zabbix agent port:** `10051` (mapped to host `0.0.0.0:10051`)
|
||||||
|
- **Grafana** has NO external port mapping — accessed only through Pangolin
|
||||||
|
|
||||||
|
### Docker Compose Location
|
||||||
|
|
||||||
|
All stack definitions: `/opt/stacks/zabbix/compose.yml`
|
||||||
|
|
||||||
|
Environment variables: `/opt/stacks/zabbix/.env`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Zabbix API Reference
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
Zabbix 7.4 uses Bearer token authentication:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <api_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
API tokens are generated in Zabbix UI: **User Settings → API tokens**
|
||||||
|
|
||||||
|
> **Important:** The `apiinfo.version` method MUST be called WITHOUT the Authorization header.
|
||||||
|
> All other methods require it.
|
||||||
|
|
||||||
|
### Base Request Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "<method_name>",
|
||||||
|
"params": { ... },
|
||||||
|
"id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Endpoint
|
||||||
|
|
||||||
|
| Context | URL |
|
||||||
|
|---------|-----|
|
||||||
|
| From `zabbix-server` or `grafana` container | `http://zabbix-frontend:8080/api_jsonrpc.php` |
|
||||||
|
| From the Docker host | `docker exec zabbix-frontend curl -s -X POST http://localhost:8080/api_jsonrpc.php ...` |
|
||||||
|
| From external / Pulse | `https://zabbix.wulfconsulting.cloud/api_jsonrpc.php` |
|
||||||
|
|
||||||
|
### Key API Methods Used
|
||||||
|
|
||||||
|
| Method | Purpose | Notes |
|
||||||
|
|--------|---------|-------|
|
||||||
|
| `host.get` | List hosts, get groups/templates | Use `selectHostGroups`, `selectParentTemplates` |
|
||||||
|
| `hostgroup.get` | List host groups | Filter by `Clients/` or `ISP/` prefix |
|
||||||
|
| `trigger.get` | Get triggers, active problems | `only_true: true` for currently-firing |
|
||||||
|
| `problem.get` | Get current problems | `recent: true` for unresolved |
|
||||||
|
| `event.get` | Get events (problems + recoveries) | Use `time_from`/`time_to`, `value: 0` for recovery |
|
||||||
|
| `mediatype.get` | Get notification media types | `selectMessageTemplates` for templates |
|
||||||
|
| `mediatype.update` | Update webhook scripts/templates | Include full `script`, `parameters`, `message_templates` |
|
||||||
|
| `action.get` | Get trigger actions | `selectOperations`, `selectRecoveryOperations` |
|
||||||
|
| `action.update` | Update actions | Add `recovery_operations` |
|
||||||
|
| `trigger.update` | Rename triggers, update descriptions | Use `description` (name) and `comments` (description text) |
|
||||||
|
| `user.get` | Get users and their media | `selectMedias` for notification channels |
|
||||||
|
| `template.get` | Get templates and their triggers | `selectTriggers` |
|
||||||
|
|
||||||
|
### Zabbix API Quirks (Zabbix 7.4)
|
||||||
|
|
||||||
|
- **Trigger `description` = trigger name** (not the description text). The description text is in `comments`.
|
||||||
|
- **Status codes:** `0` = enabled, `1` = disabled for both media types and actions.
|
||||||
|
- **`templateid` on host triggers:** If `0`, the trigger was created directly on the host (not inherited from a template). Non-zero = the parent trigger ID on the template.
|
||||||
|
- **Severity levels:** `0`=Not classified, `1`=Information, `2`=Warning, `3`=Average, `4`=High, `5`=Disaster
|
||||||
|
- **`{EVENT.VALUE}`:** `1` = problem, `0` = OK/recovery
|
||||||
|
- **`{EVENT.NSEVERITY}`:** Numeric severity (0-5)
|
||||||
|
- **Webhook scripts** run in Zabbix's built-in Duktape JavaScript engine (ES5 only — no `let`, `const`, arrow functions, template literals, `Array.find`, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Host Organization & Data Model
|
||||||
|
|
||||||
|
### Host Group Hierarchy
|
||||||
|
|
||||||
|
Every monitored host is assigned to multiple groups:
|
||||||
|
|
||||||
|
```
|
||||||
|
Host: "Kuhn's Quality Foods"
|
||||||
|
├── Clients/Kuhn's Quality Foods ← client identity
|
||||||
|
├── ISP/Zito Media, L.P. ← internet provider
|
||||||
|
└── Datto RMM Sites ← RMM platform category
|
||||||
|
```
|
||||||
|
|
||||||
|
This triple-grouping enables:
|
||||||
|
- **Per-client dashboards** — filter by `Clients/` group
|
||||||
|
- **ISP outage detection** — if 3+ hosts on same ISP go down = likely ISP issue
|
||||||
|
- **RMM correlation** — cross-reference Zabbix with Datto RMM agent status
|
||||||
|
|
||||||
|
### Client Groups (46)
|
||||||
|
|
||||||
|
| ID | Name |
|
||||||
|
|----|------|
|
||||||
|
| 84 | Clients/3 Rivers Express |
|
||||||
|
| 90 | Clients/ABC Fire Extinguisher Inc |
|
||||||
|
| 49 | Clients/ADM Signs |
|
||||||
|
| 40 | Clients/Advanced Masonry |
|
||||||
|
| 50 | Clients/Alabek Commercial Roofing Corp. |
|
||||||
|
| 33 | Clients/All Saints Catholic Church |
|
||||||
|
| 51 | Clients/Attica Hub/Seneca Publishing |
|
||||||
|
| 42 | Clients/Bella Diamond LLC |
|
||||||
|
| 28 | Clients/Blake Dentistry |
|
||||||
|
| 85 | Clients/Bosak Eyecare & Optical |
|
||||||
|
| 55 | Clients/Bridges Health Partners Services, LLC |
|
||||||
|
| 37 | Clients/Brodaks |
|
||||||
|
| 45 | Clients/Broker's Settlement Services, Inc. |
|
||||||
|
| 43 | Clients/Brooks Diamonds |
|
||||||
|
| 57 | Clients/Buffalo Glass Block |
|
||||||
|
| 60 | Clients/CUMI America |
|
||||||
|
| 61 | Clients/Chartiers Animal Hospital Ltd |
|
||||||
|
| 58 | Clients/Cincinnati Glass Block |
|
||||||
|
| 56 | Clients/Clista Electric Inc. |
|
||||||
|
| 35 | Clients/ConnecTel, Inc. |
|
||||||
|
| 26 | Clients/Finn Chiropractic Group |
|
||||||
|
| 59 | Clients/Frew Plumbing, Heating, & Air |
|
||||||
|
| 53 | Clients/Greco Gas |
|
||||||
|
| 65 | Clients/Heart Prints Center for Early Education |
|
||||||
|
| 47 | Clients/Hergenroeder, Rega, Ewing, & Kennedy, LLC |
|
||||||
|
| 69 | Clients/Hynes Industries |
|
||||||
|
| 48 | Clients/Insurance Restoration Consultants, Inc. |
|
||||||
|
| 32 | Clients/Kuhn's Quality Foods |
|
||||||
|
| 44 | Clients/Loss Prevention Services |
|
||||||
|
| 80 | Clients/MDS Energy Development, LLC |
|
||||||
|
| 88 | Clients/Marsico Financial Group, LLC |
|
||||||
|
| 62 | Clients/Nordmann Roofing |
|
||||||
|
| 68 | Clients/North Eastern Uniforms & Equipment Inc |
|
||||||
|
| 38 | Clients/POH+W Architects |
|
||||||
|
| 75 | Clients/Penn Energy Resources |
|
||||||
|
| 86 | Clients/Pittsburgh Financial Consultants |
|
||||||
|
| 87 | Clients/Premier Automation Holdings, Inc. |
|
||||||
|
| 23 | Clients/Seubert and Associates |
|
||||||
|
| 91 | Clients/Superior Distributing Co |
|
||||||
|
| 30 | Clients/TK Plastics Company, Inc. |
|
||||||
|
| 70 | Clients/Thoroughbred Construction Group |
|
||||||
|
| 66 | Clients/Thrasher Group, Inc. |
|
||||||
|
| 71 | Clients/Universal Plastics |
|
||||||
|
| 73 | Clients/Universal Plastics Latrobe |
|
||||||
|
| 54 | Clients/V-Systems |
|
||||||
|
| 76 | Clients/Vorteq Coil Finishers |
|
||||||
|
|
||||||
|
### ISP Groups (24)
|
||||||
|
|
||||||
|
| ID | Name |
|
||||||
|
|----|------|
|
||||||
|
| 25 | ISP/AT&T Enterprises, LLC |
|
||||||
|
| 27 | ISP/Armstrong |
|
||||||
|
| 24 | ISP/Bigleaf Networks, Inc. |
|
||||||
|
| 34 | ISP/Buckeye Cablevision, Inc. |
|
||||||
|
| 52 | ISP/Charter Communications Inc |
|
||||||
|
| 74 | ISP/Citizens Telecommunication Technologies, Inc |
|
||||||
|
| 67 | ISP/CityNet |
|
||||||
|
| 31 | ISP/Comcast Cable Communications, LLC |
|
||||||
|
| 46 | ISP/DQE Communications LLC |
|
||||||
|
| 63 | ISP/Expedient |
|
||||||
|
| 77 | ISP/Fidium |
|
||||||
|
| 89 | ISP/Frontier Communications of America, Inc. |
|
||||||
|
| 82 | ISP/GeoLinks |
|
||||||
|
| 78 | ISP/JACKSON ENERGY AUTHORITY |
|
||||||
|
| 39 | ISP/Level 3 Parent, LLC |
|
||||||
|
| 92 | ISP/Metalink Technologies, Inc. |
|
||||||
|
| 72 | ISP/OneCleveland |
|
||||||
|
| 64 | ISP/Space Exploration Technologies Corporation |
|
||||||
|
| 83 | ISP/UPMC |
|
||||||
|
| 79 | ISP/Ultimate Internet Access, Inc |
|
||||||
|
| 36 | ISP/Verizon Business |
|
||||||
|
| 81 | ISP/Wave Broadband |
|
||||||
|
| 41 | ISP/Windstream Communications LLC |
|
||||||
|
| 29 | ISP/Zito Media, L.P. |
|
||||||
|
|
||||||
|
### Other Groups
|
||||||
|
|
||||||
|
| ID | Name | Purpose |
|
||||||
|
|----|------|---------|
|
||||||
|
| 19 | Applications | Application-level monitoring |
|
||||||
|
| 20 | Databases | Database servers |
|
||||||
|
| 22 | Datto RMM Sites | Hosts also managed by Datto RMM |
|
||||||
|
| 5 | Discovered hosts | Auto-discovered hosts |
|
||||||
|
| 7 | Hypervisors | Virtualization hosts |
|
||||||
|
| 2 | Linux servers | Linux-based systems |
|
||||||
|
| 93 | Studio Imagine | Internal project |
|
||||||
|
| 6 | Virtual machines | VMs |
|
||||||
|
| 4 | Zabbix servers | Zabbix infrastructure |
|
||||||
|
|
||||||
|
### Host Pattern
|
||||||
|
|
||||||
|
All client-site hosts currently use the **ICMP Ping** template (templateid: 10564) for
|
||||||
|
basic up/down monitoring. A typical host:
|
||||||
|
|
||||||
|
```
|
||||||
|
Name: "Kuhn's Quality Foods"
|
||||||
|
Status: enabled
|
||||||
|
Groups: [Clients/Kuhn's Quality Foods, ISP/Zito Media L.P., Datto RMM Sites]
|
||||||
|
Templates: [ICMP Ping]
|
||||||
|
```
|
||||||
|
|
||||||
|
**77 total hosts** across the system (including Zabbix infrastructure hosts).
|
||||||
|
|
||||||
|
### Global Macros
|
||||||
|
|
||||||
|
| Macro | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| `{$SNMP_COMMUNITY}` | `public` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Notification Pipeline
|
||||||
|
|
||||||
|
### Current Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Zabbix Trigger Fires
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Action: "Send ntfy to NOC" (actionid: 7)
|
||||||
|
├── Problem → ntfy webhook (mediatypeid: 102)
|
||||||
|
└── Recovery → ntfy webhook (same media type, different template)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ntfy Webhook Script (Duktape JS)
|
||||||
|
├── Determines problem vs recovery (EVENT.VALUE)
|
||||||
|
├── Sets 🔴 red_circle (problem) or 🟢 green_circle (recovery)
|
||||||
|
├── Sets ntfy priority (high for problems, low for recovery)
|
||||||
|
├── Adds Click URL → Zabbix event page
|
||||||
|
├── For problems: queries Zabbix API for other active issues on same client
|
||||||
|
└── POSTs to ntfy → http://ntfy/noc-alerts
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ntfy Push Notification → user's phone
|
||||||
|
```
|
||||||
|
|
||||||
|
### Action Configuration
|
||||||
|
|
||||||
|
**Action:** "Send ntfy to NOC" (actionid: 7)
|
||||||
|
- **Trigger condition:** Severity >= High (conditiontype 4, operator 5, value 4)
|
||||||
|
- **Operations:** Send message to user group "Executive" (usrgrpid: 14)
|
||||||
|
- **Recovery operations:** Notify all involved (operationtype: 11)
|
||||||
|
- **Uses default messages** (default_msg: 1) — pulls from media type templates
|
||||||
|
|
||||||
|
### Users & Media
|
||||||
|
|
||||||
|
| User | Role | ntfy Media |
|
||||||
|
|------|------|------------|
|
||||||
|
| Lorentz Hinrichsen (`lorentz@wulfconsulting.com`) | Super admin (3) | `noc-alerts` topic (enabled) |
|
||||||
|
| Tom Carlin (`tom@wulfconsulting.com`) | Super admin (3) | *(removed — was duplicate to same topic)* |
|
||||||
|
| Admin | Super admin (3) | *(none)* |
|
||||||
|
| guest | Guest (4) | *(none)* |
|
||||||
|
|
||||||
|
### ntfy Webhook Script (Current)
|
||||||
|
|
||||||
|
The webhook script runs inside Zabbix Server's Duktape JS engine. It:
|
||||||
|
|
||||||
|
1. Parses parameters from Zabbix macros
|
||||||
|
2. Determines if this is a problem or recovery event
|
||||||
|
3. For problems: makes 2 internal Zabbix API calls to find related active issues for the same client
|
||||||
|
4. Formats and sends the ntfy notification with appropriate tags, priority, and click URL
|
||||||
|
|
||||||
|
**Full script and parameters** are documented in `/opt/stacks/zabbix/notification-changes.md`
|
||||||
|
|
||||||
|
### Message Templates (Current)
|
||||||
|
|
||||||
|
| Event | ntfy Tag | Subject | Body |
|
||||||
|
|-------|----------|---------|------|
|
||||||
|
| Problem | 🔴 `red_circle` | `{HOST.NAME} — {EVENT.NAME}` | Trigger description + started time + duration + related issues |
|
||||||
|
| Recovery | 🟢 `green_circle` | `Resolved: {HOST.NAME} — {EVENT.NAME}` | "Host is back online." + downtime + restored time |
|
||||||
|
| Update | *(inherits)* | `Updated: {HOST.NAME} — {EVENT.NAME}` | Who updated + action + status |
|
||||||
|
|
||||||
|
### Notification Example
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
```
|
||||||
|
🔴 Kuhn's Quality Foods — Host Unreachable
|
||||||
|
|
||||||
|
Host failed to respond to 3 consecutive ICMP ping
|
||||||
|
requests. The site may be offline, the network path
|
||||||
|
disrupted, or the device powered off.
|
||||||
|
|
||||||
|
Started: 2026.03.11 at 14:22:10
|
||||||
|
Duration: 5m 30s
|
||||||
|
|
||||||
|
── Other active issues (Kuhn's Quality Foods) ──
|
||||||
|
· High Packet Loss — Kuhn's SW-Core
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recovery:**
|
||||||
|
```
|
||||||
|
🟢 Resolved: Kuhn's Quality Foods — Host Unreachable
|
||||||
|
|
||||||
|
Host is back online.
|
||||||
|
|
||||||
|
Downtime: 22m 15s
|
||||||
|
Restored: 2026.03.11 at 14:44:25
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available but Disabled Media Types
|
||||||
|
|
||||||
|
These exist in Zabbix but are disabled. Can be enabled if needed:
|
||||||
|
- Email, Email (HTML), Gmail, Office365, SMS
|
||||||
|
- Discord, Slack, MS Teams, MS Teams Workflow, Telegram
|
||||||
|
- Jira, Jira Service Management, ServiceNow, Zendesk, PagerDuty, Opsgenie
|
||||||
|
- Many others (40+ webhook integrations available)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Trigger Naming Conventions
|
||||||
|
|
||||||
|
### Design Principles (Established 2026-03-11)
|
||||||
|
|
||||||
|
Triggers were renamed from Zabbix defaults to be **executive-friendly**:
|
||||||
|
|
||||||
|
| Default Zabbix Name | Current Name | Severity |
|
||||||
|
|---------------------|-------------|----------|
|
||||||
|
| `ICMP Ping: Unavailable by ICMP ping` | **Host Unreachable** | High (4) |
|
||||||
|
| `ICMP Ping: High ICMP ping loss` | **High Packet Loss** | Warning (2) |
|
||||||
|
| `ICMP Ping: High ICMP ping response time` | **Slow Response Time** | Warning (2) |
|
||||||
|
|
||||||
|
### Naming Rules
|
||||||
|
|
||||||
|
1. **No technical jargon in trigger names** — use impact-based language
|
||||||
|
2. **Trigger descriptions (comments field)** contain the technical detail: what the check does, thresholds, possible causes
|
||||||
|
3. **Keep names short** — they appear in ntfy subjects, Teams cards, dashboards
|
||||||
|
4. **No host name in trigger name** — `{HOST.NAME}` is added by the notification template
|
||||||
|
|
||||||
|
### Cisco Triggers (Not Yet Renamed)
|
||||||
|
|
||||||
|
The Cisco Catalyst SNMP templates have their own ICMP triggers that still use the old naming:
|
||||||
|
- `Cisco Catalyst 3750V2-24FS: Unavailable by ICMP ping`
|
||||||
|
- `Cisco Catalyst 3750V2-24FS: High ICMP ping loss`
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
These are on separate templates (not the "ICMP Ping" template) and have `templateid: 0` on host triggers. Renaming these requires updating each Cisco template individually.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Grafana Integration
|
||||||
|
|
||||||
|
### Plugin
|
||||||
|
|
||||||
|
- **alexanderzobnin-zabbix-app** v6.2.1 — connects directly to Zabbix API
|
||||||
|
- Datasource URL (internal): `http://zabbix-frontend:8080/api_jsonrpc.php`
|
||||||
|
|
||||||
|
### Planned: MSP Executive Status Board
|
||||||
|
|
||||||
|
Design goal: at-a-glance dashboard for management showing:
|
||||||
|
|
||||||
|
| Panel | Type | Data Source |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| Current problems by severity | Stat panels | Zabbix problems |
|
||||||
|
| Problems per client | Bar gauge | Zabbix trigger groups |
|
||||||
|
| Active problem list | Table | Zabbix problems |
|
||||||
|
| SLA/uptime per client | Zabbix SLA panel | Zabbix SLA |
|
||||||
|
| Host status grid | Status map plugin | Zabbix hosts |
|
||||||
|
|
||||||
|
Filter by `Clients/` host groups. Use Grafana variables for client selection dropdown.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Pulse Integration Architecture
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
**Pulse** is the internal Node.js/TypeScript web app with API access to:
|
||||||
|
- Zabbix (this system)
|
||||||
|
- PSA (ticketing)
|
||||||
|
- Datto RMM
|
||||||
|
- Veeam (backups)
|
||||||
|
- Microsoft Graph (Teams, email)
|
||||||
|
- Zoom
|
||||||
|
- Apprise (notification routing)
|
||||||
|
|
||||||
|
### Planned: Morning NOC Summary
|
||||||
|
|
||||||
|
Full architecture document: `/opt/stacks/zabbix/pulse-morning-summary-architecture.md`
|
||||||
|
|
||||||
|
**Key points:**
|
||||||
|
- Scheduled job at 6:30 AM weekdays
|
||||||
|
- Aggregates data from Zabbix + PSA + Veeam in parallel
|
||||||
|
- Sends Teams adaptive card (direct via MS Graph), email/ntfy (via Apprise)
|
||||||
|
- User-configurable delivery preferences
|
||||||
|
- Killer feature: **unmatched alerts** — Zabbix problems with no PSA ticket
|
||||||
|
|
||||||
|
### Zabbix API Queries for Pulse
|
||||||
|
|
||||||
|
**Get all open problems with host/client context:**
|
||||||
|
```typescript
|
||||||
|
// 1. Get open problems
|
||||||
|
const problems = await zabbixApi('problem.get', {
|
||||||
|
recent: true,
|
||||||
|
sortfield: ['eventid'],
|
||||||
|
sortorder: 'DESC'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Get host → client mapping (cache this)
|
||||||
|
const hosts = await zabbixApi('host.get', {
|
||||||
|
output: ['hostid', 'host', 'name'],
|
||||||
|
selectHostGroups: ['groupid', 'name']
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Build client map
|
||||||
|
const clientMap = {};
|
||||||
|
for (const host of hosts) {
|
||||||
|
const clientGroup = host.hostgroups.find(g => g.name.startsWith('Clients/'));
|
||||||
|
if (clientGroup) {
|
||||||
|
clientMap[host.hostid] = clientGroup.name.replace('Clients/', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Get overnight resolved events:**
|
||||||
|
```typescript
|
||||||
|
const resolved = await zabbixApi('event.get', {
|
||||||
|
source: 0,
|
||||||
|
object: 0,
|
||||||
|
value: 0, // recovery events only
|
||||||
|
time_from: Math.floor(yesterday6pm.getTime() / 1000),
|
||||||
|
time_to: Math.floor(today630am.getTime() / 1000),
|
||||||
|
selectHosts: ['name'],
|
||||||
|
selectRelatedObject: ['description'],
|
||||||
|
sortfield: ['clock'],
|
||||||
|
sortorder: 'DESC'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Development Patterns & Gotchas
|
||||||
|
|
||||||
|
### Zabbix Webhook Script Constraints
|
||||||
|
|
||||||
|
The webhook script runs in **Duktape** (ES5 JavaScript engine):
|
||||||
|
|
||||||
|
**DO:**
|
||||||
|
```javascript
|
||||||
|
var x = 'hello'; // var only
|
||||||
|
for (var i = 0; i < arr.length; i++) { } // classic for loops
|
||||||
|
JSON.parse(), JSON.stringify() // available
|
||||||
|
new HttpRequest() // Zabbix's HTTP client
|
||||||
|
btoa() // Base64 encoding available
|
||||||
|
```
|
||||||
|
|
||||||
|
**DON'T:**
|
||||||
|
```javascript
|
||||||
|
let x = 'hello'; // NO let/const
|
||||||
|
const y = () => {}; // NO arrow functions
|
||||||
|
`template ${literal}`; // NO template literals
|
||||||
|
arr.find(x => x.id === 1); // NO Array.find/includes/map
|
||||||
|
for (const x of arr) {} // NO for...of
|
||||||
|
```
|
||||||
|
|
||||||
|
### HttpRequest in Webhooks
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
var req = new HttpRequest();
|
||||||
|
req.addHeader('Content-Type: application/json');
|
||||||
|
req.addHeader('Authorization: Bearer TOKEN');
|
||||||
|
|
||||||
|
var resp = req.post(url, body); // POST
|
||||||
|
var resp = req.put(url, body); // PUT
|
||||||
|
var resp = req.get(url); // GET
|
||||||
|
|
||||||
|
var status = req.getStatus(); // HTTP status code
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple HttpRequest instances CAN be created in the same script (used for the related-problems enrichment that calls Zabbix API before calling ntfy).
|
||||||
|
|
||||||
|
### API Update Patterns
|
||||||
|
|
||||||
|
When updating a media type, you must include the FULL array for `parameters` and `message_templates` — they're **replaced entirely**, not merged.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# WRONG — this deletes all other parameters
|
||||||
|
mediatype.update({ parameters: [{"name": "new_param", "value": "x"}] })
|
||||||
|
|
||||||
|
# RIGHT — include ALL parameters
|
||||||
|
mediatype.update({ parameters: [
|
||||||
|
{"name": "endpoint", "value": "http://ntfy/noc-alerts"},
|
||||||
|
{"name": "username", "value": "monitoring"},
|
||||||
|
# ... all existing params ...
|
||||||
|
{"name": "new_param", "value": "x"}
|
||||||
|
] })
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trigger Description vs Comments
|
||||||
|
|
||||||
|
This is confusing in the Zabbix API:
|
||||||
|
- `description` field = **the trigger name** (what you see in the UI)
|
||||||
|
- `comments` field = **the description text** (the explanation paragraph)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"triggerid": "23176",
|
||||||
|
"description": "Host Unreachable", // ← this is the NAME
|
||||||
|
"comments": "Host failed to respond to..." // ← this is the DESCRIPTION
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Host Group Filtering Pattern
|
||||||
|
|
||||||
|
To get the client name for a host:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Get host groups
|
||||||
|
var groups = hostData.hostgroups;
|
||||||
|
var clientName = null;
|
||||||
|
for (var i = 0; i < groups.length; i++) {
|
||||||
|
if (groups[i].name.indexOf("Clients/") === 0) {
|
||||||
|
clientName = groups[i].name.replace("Clients/", "");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// clientName = "Kuhn's Quality Foods"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Exec for API Calls
|
||||||
|
|
||||||
|
Since the Zabbix frontend doesn't have ports mapped to the host, API calls from the host must go through `docker exec`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec zabbix-frontend curl -s -X POST \
|
||||||
|
"http://localhost:8080/api_jsonrpc.php" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-d '{"jsonrpc":"2.0","method":"...","params":{...},"id":1}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use Python for complex payloads (escaping JSON in bash is fragile):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json, subprocess
|
||||||
|
|
||||||
|
payload = json.dumps({...})
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "exec", "zabbix-frontend", "curl", "-s", "-X", "POST",
|
||||||
|
"http://localhost:8080/api_jsonrpc.php",
|
||||||
|
"-H", "Content-Type: application/json",
|
||||||
|
"-H", f"Authorization: Bearer {TOKEN}",
|
||||||
|
"-d", payload],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
```
|
||||||
|
|
||||||
|
### External API Access (from Pulse or other servers)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const response = await fetch('https://zabbix.wulfconsulting.cloud/api_jsonrpc.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${ZABBIX_API_TOKEN}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'problem.get',
|
||||||
|
params: { recent: true },
|
||||||
|
id: 1
|
||||||
|
})
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. API Cookbook
|
||||||
|
|
||||||
|
### Get All Active Problems
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "problem.get",
|
||||||
|
"params": {
|
||||||
|
"output": "extend",
|
||||||
|
"recent": true,
|
||||||
|
"sortfield": ["eventid"],
|
||||||
|
"sortorder": "DESC"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Active Problems for a Specific Client
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "trigger.get",
|
||||||
|
"params": {
|
||||||
|
"output": ["triggerid", "description", "lastchange", "priority"],
|
||||||
|
"groupids": ["32"],
|
||||||
|
"only_true": true,
|
||||||
|
"selectHosts": ["name"],
|
||||||
|
"skipDependent": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
*(groupid 32 = Clients/Kuhn's Quality Foods)*
|
||||||
|
|
||||||
|
### Get All Hosts with Client + ISP Mapping
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "host.get",
|
||||||
|
"params": {
|
||||||
|
"output": ["hostid", "host", "name", "status"],
|
||||||
|
"selectHostGroups": ["groupid", "name"],
|
||||||
|
"selectParentTemplates": ["templateid", "name"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Recovery Events in a Time Window
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "event.get",
|
||||||
|
"params": {
|
||||||
|
"source": 0,
|
||||||
|
"object": 0,
|
||||||
|
"value": 0,
|
||||||
|
"time_from": 1741647600,
|
||||||
|
"time_to": 1741692600,
|
||||||
|
"selectHosts": ["name"],
|
||||||
|
"selectRelatedObject": ["description"],
|
||||||
|
"sortfield": ["clock"],
|
||||||
|
"sortorder": "DESC"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update a Trigger Name + Description
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "trigger.update",
|
||||||
|
"params": {
|
||||||
|
"triggerid": "23176",
|
||||||
|
"description": "Host Unreachable",
|
||||||
|
"comments": "Host failed to respond to 3 consecutive ICMP ping requests."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Media Type with Full Details
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "mediatype.get",
|
||||||
|
"params": {
|
||||||
|
"output": "extend",
|
||||||
|
"selectMessageTemplates": "extend",
|
||||||
|
"mediatypeids": ["102"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test ntfy Notification Manually
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec zabbix-frontend curl -s -X PUT \
|
||||||
|
-H "Authorization: Basic $(echo -n 'monitoring:GBigt1231#' | base64)" \
|
||||||
|
-H "Title: Test Notification" \
|
||||||
|
-H "Priority: 3" \
|
||||||
|
-H "Tags: red_circle" \
|
||||||
|
"http://ntfy/noc-alerts" \
|
||||||
|
-d "This is a test notification from Zabbix"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- **Notification changes (before/after + restoration):** `/opt/stacks/zabbix/notification-changes.md`
|
||||||
|
- **Pulse morning summary architecture:** `/opt/stacks/zabbix/pulse-morning-summary-architecture.md`
|
||||||
|
- **Docker Compose:** `/opt/stacks/zabbix/compose.yml`
|
||||||
|
- **Zabbix SSO setup:** `/opt/stacks/zabbix/ENTRA-AUTHENTIK-ZABBIX-SSO.md`
|
||||||
|
- **Zabbix 7.4 API docs:** https://www.zabbix.com/documentation/7.4/en/manual/api
|
||||||
424
lib/services/engagement-sync-service.ts
Normal file
424
lib/services/engagement-sync-service.ts
Normal file
|
|
@ -0,0 +1,424 @@
|
||||||
|
/**
|
||||||
|
* Engagement Sync Service
|
||||||
|
* Orchestrates Microsoft Graph → PostgreSQL sync for employee engagement data
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getMsgraphClient } from './msgraph-factory';
|
||||||
|
import type { CalendarEvent, UserMessage } from './msgraph-client';
|
||||||
|
import { postgresClient } from './postgres-client';
|
||||||
|
|
||||||
|
const PERIODS = ['D7', 'D30', 'D90'] as const;
|
||||||
|
type Period = typeof PERIODS[number];
|
||||||
|
|
||||||
|
const PERIOD_DAYS: Record<Period, number> = { D7: 7, D30: 30, D90: 90 };
|
||||||
|
|
||||||
|
interface CalendarBucket {
|
||||||
|
meetingCount: number;
|
||||||
|
durationSeconds: number;
|
||||||
|
externalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeCalendarBuckets(
|
||||||
|
events: CalendarEvent[],
|
||||||
|
userEmail: string,
|
||||||
|
internalDomains: Set<string>,
|
||||||
|
now: Date
|
||||||
|
): Record<Period, CalendarBucket> {
|
||||||
|
const cutoffs: Record<Period, number> = {
|
||||||
|
D7: now.getTime() - 7 * 24 * 60 * 60 * 1000,
|
||||||
|
D30: now.getTime() - 30 * 24 * 60 * 60 * 1000,
|
||||||
|
D90: now.getTime() - 90 * 24 * 60 * 60 * 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result: Record<Period, CalendarBucket> = {
|
||||||
|
D7: { meetingCount: 0, durationSeconds: 0, externalCount: 0 },
|
||||||
|
D30: { meetingCount: 0, durationSeconds: 0, externalCount: 0 },
|
||||||
|
D90: { meetingCount: 0, durationSeconds: 0, externalCount: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const eventTime = new Date(event.start.dateTime).getTime();
|
||||||
|
const durationSec = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round(
|
||||||
|
(new Date(event.end.dateTime).getTime() - new Date(event.start.dateTime).getTime()) / 1000
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// An attendee is external if their domain is not in the org's verified domains
|
||||||
|
// (filter out the user themselves to avoid false positives)
|
||||||
|
const hasExternal = event.attendees.some(a => {
|
||||||
|
const email = (a.emailAddress?.address ?? '').toLowerCase();
|
||||||
|
if (email === userEmail.toLowerCase()) return false;
|
||||||
|
const domain = email.split('@')[1];
|
||||||
|
return domain && !internalDomains.has(domain);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const period of PERIODS) {
|
||||||
|
if (eventTime >= cutoffs[period]) {
|
||||||
|
result[period].meetingCount++;
|
||||||
|
result[period].durationSeconds += durationSec;
|
||||||
|
if (hasExternal) result[period].externalCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EngagementSyncService {
|
||||||
|
private syncInProgress = false;
|
||||||
|
|
||||||
|
isSyncInProgress(): boolean {
|
||||||
|
return this.syncInProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sync(): Promise<{ usersUpserted: number; snapshotsUpserted: number }> {
|
||||||
|
if (this.syncInProgress) {
|
||||||
|
throw new Error('Engagement sync already in progress');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncInProgress = true;
|
||||||
|
const startTime = Date.now();
|
||||||
|
console.log('[ENGAGEMENT-SYNC] Starting sync...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = getMsgraphClient();
|
||||||
|
|
||||||
|
// 1. Fetch org's verified domains (to identify external attendees)
|
||||||
|
const orgDomains = await client.getOrganizationDomains();
|
||||||
|
const internalDomains = new Set(orgDomains);
|
||||||
|
console.log(`[ENGAGEMENT-SYNC] Internal domains: ${[...internalDomains].join(', ')}`);
|
||||||
|
|
||||||
|
// 2. Sync users
|
||||||
|
console.log('[ENGAGEMENT-SYNC] Fetching Graph users...');
|
||||||
|
const graphUsers = await client.getUsers();
|
||||||
|
const licencedUsers = graphUsers.filter(u => u.mail || u.userPrincipalName);
|
||||||
|
|
||||||
|
for (const user of licencedUsers) {
|
||||||
|
const email = (user.mail || user.userPrincipalName || '').toLowerCase();
|
||||||
|
if (!email) continue;
|
||||||
|
|
||||||
|
await postgresClient.query(
|
||||||
|
`INSERT INTO graph_users (id, display_name, email, job_title, department, account_enabled, synced_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
display_name = EXCLUDED.display_name,
|
||||||
|
email = EXCLUDED.email,
|
||||||
|
job_title = EXCLUDED.job_title,
|
||||||
|
department = EXCLUDED.department,
|
||||||
|
account_enabled = EXCLUDED.account_enabled,
|
||||||
|
synced_at = NOW()`,
|
||||||
|
[user.id, user.displayName, email, user.jobTitle, user.department, user.accountEnabled ?? true]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`[ENGAGEMENT-SYNC] Upserted ${licencedUsers.length} graph users`);
|
||||||
|
|
||||||
|
// 3. Fetch Teams + Email activity reports for each period
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Build snapshot data map: email → period → data
|
||||||
|
type SnapshotData = {
|
||||||
|
teamsChatMessages: number;
|
||||||
|
teamsPrivateMessages: number;
|
||||||
|
teamsCalls: number;
|
||||||
|
teamsMeetingsAttended: number;
|
||||||
|
teamsMeetingsOrganized: number;
|
||||||
|
audioDurationSeconds: number;
|
||||||
|
emailsSent: number;
|
||||||
|
emailsReceived: number;
|
||||||
|
emailsRead: number;
|
||||||
|
meetingDurationSeconds: number;
|
||||||
|
meetingsWithExternal: number;
|
||||||
|
lastActivityDate: string | null;
|
||||||
|
afterHoursMessages: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshotMap = new Map<string, Map<Period, SnapshotData>>();
|
||||||
|
|
||||||
|
for (const period of PERIODS) {
|
||||||
|
console.log(`[ENGAGEMENT-SYNC] Fetching activity for period ${period}...`);
|
||||||
|
|
||||||
|
const [teamsRows, emailRows] = await Promise.all([
|
||||||
|
client.getTeamsActivity(period).catch(err => {
|
||||||
|
console.warn(`[ENGAGEMENT-SYNC] Teams activity failed for ${period}:`, err.message);
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
client.getEmailActivity(period).catch(err => {
|
||||||
|
console.warn(`[ENGAGEMENT-SYNC] Email activity failed for ${period}:`, err.message);
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const teamsMap = new Map(teamsRows.map(r => [r.userPrincipalName.toLowerCase(), r]));
|
||||||
|
const emailMap = new Map(emailRows.map(r => [r.userPrincipalName.toLowerCase(), r]));
|
||||||
|
const allEmails = new Set([
|
||||||
|
...teamsRows.map(r => r.userPrincipalName.toLowerCase()),
|
||||||
|
...emailRows.map(r => r.userPrincipalName.toLowerCase()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const email of allEmails) {
|
||||||
|
const teams = teamsMap.get(email);
|
||||||
|
const mail = emailMap.get(email);
|
||||||
|
|
||||||
|
const dates = [teams?.lastActivityDate, mail?.lastActivityDate].filter(Boolean) as string[];
|
||||||
|
const lastActivity = dates.length > 0 ? dates.sort().reverse()[0] : null;
|
||||||
|
|
||||||
|
if (!snapshotMap.has(email)) snapshotMap.set(email, new Map());
|
||||||
|
snapshotMap.get(email)!.set(period, {
|
||||||
|
teamsChatMessages: teams?.teamChatMessageCount ?? 0,
|
||||||
|
teamsPrivateMessages: teams?.privateChatMessageCount ?? 0,
|
||||||
|
teamsCalls: teams?.callCount ?? 0,
|
||||||
|
teamsMeetingsAttended: teams?.meetingsAttendedCount ?? 0,
|
||||||
|
teamsMeetingsOrganized: teams?.meetingsOrganizedCount ?? 0,
|
||||||
|
audioDurationSeconds: teams?.audioDurationSeconds ?? 0,
|
||||||
|
emailsSent: mail?.sendCount ?? 0,
|
||||||
|
emailsReceived: mail?.receiveCount ?? 0,
|
||||||
|
emailsRead: mail?.readCount ?? 0,
|
||||||
|
meetingDurationSeconds: 0,
|
||||||
|
meetingsWithExternal: 0,
|
||||||
|
lastActivityDate: lastActivity,
|
||||||
|
afterHoursMessages: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fetch calendar events per user (90 days, aggregate into all period buckets)
|
||||||
|
console.log('[ENGAGEMENT-SYNC] Fetching calendar events...');
|
||||||
|
const activeUsers = licencedUsers.filter(u => {
|
||||||
|
const email = (u.mail || u.userPrincipalName || '').toLowerCase();
|
||||||
|
return snapshotMap.has(email);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build contact email → { contactId, companyId } index for attendee matching
|
||||||
|
const contactRows = await postgresClient.query(
|
||||||
|
`SELECT id, company_id, LOWER(email_address) as e1,
|
||||||
|
LOWER(email_address2) as e2, LOWER(email_address3) as e3
|
||||||
|
FROM contacts WHERE (is_deleted = false OR is_deleted IS NULL)`
|
||||||
|
);
|
||||||
|
const contactEmailIndex = new Map<string, { contactId: number; companyId: number | null }>();
|
||||||
|
for (const row of contactRows.rows) {
|
||||||
|
for (const e of [row.e1, row.e2, row.e3]) {
|
||||||
|
if (e && !contactEmailIndex.has(e)) {
|
||||||
|
contactEmailIndex.set(e, { contactId: row.id, companyId: row.company_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const calStart = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||||
|
let calFetched = 0;
|
||||||
|
let calSkipped = 0;
|
||||||
|
|
||||||
|
for (const user of activeUsers) {
|
||||||
|
const email = (user.mail || user.userPrincipalName || '').toLowerCase();
|
||||||
|
try {
|
||||||
|
const events = await client.getUserCalendarEvents(user.id, calStart, now);
|
||||||
|
if (events.length > 0) {
|
||||||
|
const buckets = computeCalendarBuckets(events, email, internalDomains, now);
|
||||||
|
for (const period of PERIODS) {
|
||||||
|
const snap = snapshotMap.get(email)?.get(period);
|
||||||
|
if (snap) {
|
||||||
|
snap.meetingDurationSeconds = buckets[period].durationSeconds;
|
||||||
|
snap.meetingsWithExternal = buckets[period].externalCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist individual meeting records
|
||||||
|
for (const event of events) {
|
||||||
|
if (!event.id) continue;
|
||||||
|
try {
|
||||||
|
const startTime = new Date(event.start.dateTime);
|
||||||
|
const endTime = new Date(event.end.dateTime);
|
||||||
|
const durationMinutes = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round((endTime.getTime() - startTime.getTime()) / 60000)
|
||||||
|
);
|
||||||
|
const externalAttendees = event.attendees.filter(a => {
|
||||||
|
const aEmail = (a.emailAddress?.address ?? '').toLowerCase();
|
||||||
|
if (aEmail === email) return false;
|
||||||
|
const domain = aEmail.split('@')[1];
|
||||||
|
return domain && !internalDomains.has(domain);
|
||||||
|
});
|
||||||
|
const attendeeCount = event.attendees.length;
|
||||||
|
|
||||||
|
const meetingResult = await postgresClient.query(
|
||||||
|
`INSERT INTO teams_meetings
|
||||||
|
(graph_event_id, user_email, subject, start_time, end_time,
|
||||||
|
duration_minutes, is_online_meeting, attendee_count, synced_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
|
||||||
|
ON CONFLICT (user_email, graph_event_id) DO UPDATE SET
|
||||||
|
subject = EXCLUDED.subject,
|
||||||
|
start_time = EXCLUDED.start_time,
|
||||||
|
end_time = EXCLUDED.end_time,
|
||||||
|
duration_minutes = EXCLUDED.duration_minutes,
|
||||||
|
is_online_meeting = EXCLUDED.is_online_meeting,
|
||||||
|
attendee_count = EXCLUDED.attendee_count,
|
||||||
|
synced_at = NOW()
|
||||||
|
RETURNING id`,
|
||||||
|
[event.id, email, event.subject, startTime, endTime,
|
||||||
|
durationMinutes, event.isOnlineMeeting, attendeeCount]
|
||||||
|
);
|
||||||
|
const meetingId = meetingResult.rows[0]?.id;
|
||||||
|
if (!meetingId) continue;
|
||||||
|
|
||||||
|
// Re-sync attendees clean
|
||||||
|
await postgresClient.query(
|
||||||
|
`DELETE FROM teams_meeting_attendees WHERE meeting_id = $1`,
|
||||||
|
[meetingId]
|
||||||
|
);
|
||||||
|
|
||||||
|
let clientCount = 0;
|
||||||
|
for (const att of externalAttendees) {
|
||||||
|
const attEmail = (att.emailAddress?.address ?? '').toLowerCase();
|
||||||
|
const attName = att.emailAddress?.name ?? null;
|
||||||
|
const match = attEmail ? contactEmailIndex.get(attEmail) : undefined;
|
||||||
|
await postgresClient.query(
|
||||||
|
`INSERT INTO teams_meeting_attendees
|
||||||
|
(meeting_id, attendee_email, attendee_name, matched_contact_id, matched_company_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[meetingId, attEmail || null, attName,
|
||||||
|
match?.contactId ?? null, match?.companyId ?? null]
|
||||||
|
);
|
||||||
|
if (match?.companyId) clientCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await postgresClient.query(
|
||||||
|
`UPDATE teams_meetings
|
||||||
|
SET client_attendee_count = $1, has_client_attendees = $2
|
||||||
|
WHERE id = $3`,
|
||||||
|
[clientCount, clientCount > 0, meetingId]
|
||||||
|
);
|
||||||
|
} catch (meetingErr) {
|
||||||
|
const msg = meetingErr instanceof Error ? meetingErr.message : String(meetingErr);
|
||||||
|
console.warn(`[ENGAGEMENT-SYNC] Meeting persist failed for event ${event.id}: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
calFetched++;
|
||||||
|
} catch (err) {
|
||||||
|
calSkipped++;
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn(`[ENGAGEMENT-SYNC] Calendar fetch failed for ${email}: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[ENGAGEMENT-SYNC] Calendar: ${calFetched} fetched, ${calSkipped} skipped`);
|
||||||
|
|
||||||
|
// 5. Count after-hours messages per user (requires Chat.Read.All permission)
|
||||||
|
// After-hours window: 5:30 PM – 7:00 AM America/New_York
|
||||||
|
const AFTER_HOURS_START_MIN = 17 * 60 + 30; // 1050 — 5:30 PM local
|
||||||
|
const AFTER_HOURS_END_MIN = 7 * 60; // 420 — 7:00 AM local
|
||||||
|
|
||||||
|
// Returns minutes since midnight in America/New_York (handles EST/EDT automatically)
|
||||||
|
function toEasternMinutes(dt: Date): number {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'America/New_York',
|
||||||
|
hour: 'numeric', minute: 'numeric', hour12: false,
|
||||||
|
}).formatToParts(dt);
|
||||||
|
const h = parseInt(parts.find(p => p.type === 'hour')?.value ?? '0');
|
||||||
|
const m = parseInt(parts.find(p => p.type === 'minute')?.value ?? '0');
|
||||||
|
return h * 60 + m;
|
||||||
|
}
|
||||||
|
const periodCutoffs: Record<Period, number> = {
|
||||||
|
D7: now.getTime() - 7 * 24 * 60 * 60 * 1000,
|
||||||
|
D30: now.getTime() - 30 * 24 * 60 * 60 * 1000,
|
||||||
|
D90: now.getTime() - 90 * 24 * 60 * 60 * 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
let msgFetched = 0;
|
||||||
|
let msgSkipped = 0;
|
||||||
|
|
||||||
|
console.log(`[ENGAGEMENT-SYNC] Fetching after-hours messages for ${activeUsers.length} users...`);
|
||||||
|
for (const user of activeUsers) {
|
||||||
|
const email = (user.mail || user.userPrincipalName || '').toLowerCase();
|
||||||
|
if (!snapshotMap.has(email)) continue;
|
||||||
|
try {
|
||||||
|
const messages: UserMessage[] = await client.getUserMessages(user.id, calStart, now);
|
||||||
|
const afterD90 = messages.filter(msg => {
|
||||||
|
const estMin = toEasternMinutes(new Date(msg.createdDateTime));
|
||||||
|
return estMin >= AFTER_HOURS_START_MIN || estMin < AFTER_HOURS_END_MIN;
|
||||||
|
}).length;
|
||||||
|
console.log(`[ENGAGEMENT-SYNC] ${email}: ${messages.length} msgs total, ${afterD90} after-hours`);
|
||||||
|
// Bucket by period
|
||||||
|
for (const period of PERIODS) {
|
||||||
|
const snap = snapshotMap.get(email)?.get(period);
|
||||||
|
if (!snap) continue;
|
||||||
|
snap.afterHoursMessages = messages.filter(msg => {
|
||||||
|
const msgMs = new Date(msg.createdDateTime).getTime();
|
||||||
|
if (msgMs < periodCutoffs[period]) return false;
|
||||||
|
const estMin = toEasternMinutes(new Date(msg.createdDateTime));
|
||||||
|
return estMin >= AFTER_HOURS_START_MIN || estMin < AFTER_HOURS_END_MIN;
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
msgFetched++;
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn(`[ENGAGEMENT-SYNC] Message fetch failed for ${email}: ${errMsg.slice(0, 200)}`);
|
||||||
|
msgSkipped++;
|
||||||
|
}
|
||||||
|
// Small pause between users to stay under Graph API rate limits (10 req/10 sec per app)
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[ENGAGEMENT-SYNC] After-hours messages: ${msgFetched} fetched, ${msgSkipped} skipped`);
|
||||||
|
|
||||||
|
// 6. Upsert all snapshots
|
||||||
|
let totalSnapshots = 0;
|
||||||
|
for (const [email, periodMap] of snapshotMap) {
|
||||||
|
for (const [period, snap] of periodMap) {
|
||||||
|
await postgresClient.query(
|
||||||
|
`INSERT INTO engagement_snapshots (
|
||||||
|
user_email, period_type, period_end,
|
||||||
|
teams_chat_messages, teams_private_messages, teams_calls,
|
||||||
|
teams_meetings_attended, teams_meetings_organized,
|
||||||
|
emails_sent, emails_received, emails_read,
|
||||||
|
audio_duration_seconds, meeting_duration_seconds, meetings_with_external,
|
||||||
|
after_hours_messages, last_activity_date, synced_at
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,NOW())
|
||||||
|
ON CONFLICT (user_email, period_type, period_end) DO UPDATE SET
|
||||||
|
teams_chat_messages = EXCLUDED.teams_chat_messages,
|
||||||
|
teams_private_messages = EXCLUDED.teams_private_messages,
|
||||||
|
teams_calls = EXCLUDED.teams_calls,
|
||||||
|
teams_meetings_attended = EXCLUDED.teams_meetings_attended,
|
||||||
|
teams_meetings_organized = EXCLUDED.teams_meetings_organized,
|
||||||
|
emails_sent = EXCLUDED.emails_sent,
|
||||||
|
emails_received = EXCLUDED.emails_received,
|
||||||
|
emails_read = EXCLUDED.emails_read,
|
||||||
|
audio_duration_seconds = EXCLUDED.audio_duration_seconds,
|
||||||
|
meeting_duration_seconds = EXCLUDED.meeting_duration_seconds,
|
||||||
|
meetings_with_external = EXCLUDED.meetings_with_external,
|
||||||
|
after_hours_messages = EXCLUDED.after_hours_messages,
|
||||||
|
last_activity_date = EXCLUDED.last_activity_date,
|
||||||
|
synced_at = NOW()`,
|
||||||
|
[
|
||||||
|
email, period, today,
|
||||||
|
snap.teamsChatMessages, snap.teamsPrivateMessages, snap.teamsCalls,
|
||||||
|
snap.teamsMeetingsAttended, snap.teamsMeetingsOrganized,
|
||||||
|
snap.emailsSent, snap.emailsReceived, snap.emailsRead,
|
||||||
|
snap.audioDurationSeconds, snap.meetingDurationSeconds, snap.meetingsWithExternal,
|
||||||
|
snap.afterHoursMessages, snap.lastActivityDate,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
totalSnapshots++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
console.log(`[ENGAGEMENT-SYNC] Done in ${duration}ms. Users: ${licencedUsers.length}, Snapshots: ${totalSnapshots}`);
|
||||||
|
|
||||||
|
return { usersUpserted: licencedUsers.length, snapshotsUpserted: totalSnapshots };
|
||||||
|
} finally {
|
||||||
|
this.syncInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _instance: EngagementSyncService | null = null;
|
||||||
|
|
||||||
|
export function getEngagementSyncService(): EngagementSyncService {
|
||||||
|
if (!_instance) {
|
||||||
|
_instance = new EngagementSyncService();
|
||||||
|
}
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
buildActiveFilter,
|
buildActiveFilter,
|
||||||
buildDateRangeFilter,
|
buildDateRangeFilter,
|
||||||
buildContractsFilter,
|
buildContractsFilter,
|
||||||
|
buildContractServicesFilter,
|
||||||
buildProjectsFilter,
|
buildProjectsFilter,
|
||||||
buildTimeEntriesFilter,
|
buildTimeEntriesFilter,
|
||||||
buildBillingItemsFilter,
|
buildBillingItemsFilter,
|
||||||
|
|
@ -125,6 +126,9 @@ export class EntitySyncService {
|
||||||
if (entity === EntityType.CONTRACTS) {
|
if (entity === EntityType.CONTRACTS) {
|
||||||
filters.push(...buildContractsFilter());
|
filters.push(...buildContractsFilter());
|
||||||
entityLogger.info('Full sync with status filter for active contracts');
|
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) {
|
} else if (entity === EntityType.PROJECTS) {
|
||||||
filters.push(...buildProjectsFilter());
|
filters.push(...buildProjectsFilter());
|
||||||
entityLogger.info('Full sync with status filter for non-completed projects');
|
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 });
|
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
|
// For full sync, soft delete records not in the fetched set
|
||||||
// IMPORTANT: Skip soft deletes if ANY filters were applied (date, status, active, etc.)
|
// IMPORTANT: Skip soft deletes if ANY filters were applied (date, status, active, etc.)
|
||||||
// because we cannot know what records exist outside the filter criteria
|
// because we cannot know what records exist outside the filter criteria
|
||||||
|
|
|
||||||
543
lib/services/morning-summary-service.ts
Normal file
543
lib/services/morning-summary-service.ts
Normal file
|
|
@ -0,0 +1,543 @@
|
||||||
|
import { postgresClient } from './postgres-client';
|
||||||
|
import { ZabbixClient } from './zabbix-client';
|
||||||
|
|
||||||
|
export interface MorningSummaryProblem {
|
||||||
|
eventid: string;
|
||||||
|
hostid: string;
|
||||||
|
hostName: string;
|
||||||
|
clientName: string;
|
||||||
|
triggerName: string;
|
||||||
|
severity: number;
|
||||||
|
severityLabel: string;
|
||||||
|
startedAt: Date;
|
||||||
|
durationLabel: string;
|
||||||
|
acknowledged: boolean;
|
||||||
|
needsAttention: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MorningSummaryResolved {
|
||||||
|
eventid: string;
|
||||||
|
hostName: string;
|
||||||
|
clientName: string;
|
||||||
|
triggerName: string;
|
||||||
|
startedAt: Date;
|
||||||
|
resolvedAt: Date;
|
||||||
|
durationLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MorningSummary {
|
||||||
|
generatedAt: Date;
|
||||||
|
windowFrom: Date;
|
||||||
|
windowTo: Date;
|
||||||
|
isWeekendWindow: boolean;
|
||||||
|
openProblems: MorningSummaryProblem[];
|
||||||
|
resolvedOvernight: MorningSummaryResolved[];
|
||||||
|
openCount: number;
|
||||||
|
resolvedCount: number;
|
||||||
|
mttrMinutes: number | null;
|
||||||
|
clientsAffected: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookConfig {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
webhook_url: string;
|
||||||
|
enabled: boolean;
|
||||||
|
last_delivered_at: string | null;
|
||||||
|
last_status: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SummaryConfig {
|
||||||
|
weekend_suppression: boolean;
|
||||||
|
monday_extended_window: boolean;
|
||||||
|
severity_filter: number;
|
||||||
|
outages_only: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeliveryResult {
|
||||||
|
webhookId: number;
|
||||||
|
label: string;
|
||||||
|
success: boolean;
|
||||||
|
httpStatus?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVERITY_LABELS: Record<number, string> = {
|
||||||
|
0: 'Not classified',
|
||||||
|
1: 'Information',
|
||||||
|
2: 'Warning',
|
||||||
|
3: 'Average',
|
||||||
|
4: 'High',
|
||||||
|
5: 'Disaster',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
const totalMinutes = Math.floor(ms / 60000);
|
||||||
|
if (totalMinutes < 1) return '<1m';
|
||||||
|
if (totalMinutes < 60) return `${totalMinutes}m`;
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const mins = totalMinutes % 60;
|
||||||
|
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeZabbix(): ZabbixClient {
|
||||||
|
if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) {
|
||||||
|
throw new Error('Zabbix not configured: ZABBIX_API_URL and ZABBIX_API_TOKEN required');
|
||||||
|
}
|
||||||
|
return new ZabbixClient({
|
||||||
|
apiUrl: process.env.ZABBIX_API_URL,
|
||||||
|
apiToken: process.env.ZABBIX_API_TOKEN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWindowStart(now: Date, config: SummaryConfig): { from: Date; isWeekend: boolean } {
|
||||||
|
const day = now.getDay(); // 0=Sun,1=Mon
|
||||||
|
if (day === 1 && config.monday_extended_window) {
|
||||||
|
const friday = new Date(now);
|
||||||
|
friday.setDate(friday.getDate() - 3);
|
||||||
|
friday.setHours(18, 0, 0, 0);
|
||||||
|
return { from: friday, isWeekend: true };
|
||||||
|
}
|
||||||
|
const yesterday = new Date(now);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
yesterday.setHours(18, 0, 0, 0);
|
||||||
|
return { from: yesterday, isWeekend: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAdaptiveCard(summary: MorningSummary): object {
|
||||||
|
const headerText = `☀️ Morning NOC Summary${summary.isWeekendWindow ? ' — Weekend Coverage' : ''}`;
|
||||||
|
|
||||||
|
const mttrText = summary.mttrMinutes != null ? formatDuration(summary.mttrMinutes * 60000) : '—';
|
||||||
|
|
||||||
|
const statsColumns = {
|
||||||
|
type: 'ColumnSet',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
type: 'Column', width: 'stretch',
|
||||||
|
items: [{
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: `${summary.openCount} Open`,
|
||||||
|
weight: 'Bolder', color: summary.openCount > 0 ? 'Attention' : 'Default',
|
||||||
|
wrap: true,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Column', width: 'stretch',
|
||||||
|
items: [{
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: `${summary.resolvedCount} Resolved`,
|
||||||
|
weight: 'Bolder', color: summary.resolvedCount > 0 ? 'Good' : 'Default',
|
||||||
|
wrap: true,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Column', width: 'stretch',
|
||||||
|
items: [{
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: `${mttrText} MTTR`,
|
||||||
|
weight: 'Bolder', wrap: true,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const bodyItems: object[] = [
|
||||||
|
{ type: 'TextBlock', text: headerText, weight: 'Bolder', size: 'Large', wrap: true },
|
||||||
|
statsColumns,
|
||||||
|
{ type: 'TextBlock', text: ' ', spacing: 'None' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Open issues section
|
||||||
|
if (summary.openProblems.length > 0) {
|
||||||
|
const facts = summary.openProblems.map(p => ({
|
||||||
|
title: `${p.clientName} / ${p.hostName}`,
|
||||||
|
value: `${p.triggerName} — ${p.durationLabel}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
bodyItems.push({
|
||||||
|
type: 'Container',
|
||||||
|
style: 'attention',
|
||||||
|
items: [
|
||||||
|
{ type: 'TextBlock', text: 'Open Issues', weight: 'Bolder', color: 'Attention', wrap: true },
|
||||||
|
{ type: 'FactSet', facts },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
bodyItems.push({
|
||||||
|
type: 'Container',
|
||||||
|
style: 'good',
|
||||||
|
items: [{ type: 'TextBlock', text: 'All Clear — No open issues', weight: 'Bolder', color: 'Good', wrap: true }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolved overnight section
|
||||||
|
if (summary.resolvedOvernight.length > 0) {
|
||||||
|
const shown = summary.resolvedOvernight.slice(0, 5);
|
||||||
|
const extra = summary.resolvedOvernight.length - 5;
|
||||||
|
const facts = shown.map(r => ({
|
||||||
|
title: `${r.clientName} / ${r.hostName}`,
|
||||||
|
value: `${r.triggerName} — ${r.durationLabel}`,
|
||||||
|
}));
|
||||||
|
if (extra > 0) {
|
||||||
|
facts.push({ title: '', value: `+${extra} more — all resolved` });
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyItems.push({
|
||||||
|
type: 'Container',
|
||||||
|
style: 'good',
|
||||||
|
items: [
|
||||||
|
{ type: 'TextBlock', text: 'Resolved', weight: 'Bolder', color: 'Good', wrap: true },
|
||||||
|
{ type: 'FactSet', facts },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window info footer
|
||||||
|
const windowFrom = summary.windowFrom.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true });
|
||||||
|
const windowTo = summary.windowTo.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||||
|
bodyItems.push({
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: `Window: ${windowFrom} → ${windowTo}`,
|
||||||
|
size: 'Small', color: 'Default', isSubtle: true, wrap: true, spacing: 'Small',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||||
|
type: 'AdaptiveCard',
|
||||||
|
version: '1.4',
|
||||||
|
body: bodyItems,
|
||||||
|
actions: [
|
||||||
|
{ type: 'Action.OpenUrl', title: 'Open Pulse', url: 'https://pulse.wulfconsulting.cloud' },
|
||||||
|
{ type: 'Action.OpenUrl', title: 'View Problems', url: 'https://zabbix.wulfconsulting.cloud/zabbix.php?action=problem.view' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MorningSummaryService {
|
||||||
|
async getConfig(): Promise<SummaryConfig> {
|
||||||
|
const result = await postgresClient.query('SELECT * FROM morning_summary_config WHERE id = 1');
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return { weekend_suppression: true, monday_extended_window: true, severity_filter: 2, outages_only: false };
|
||||||
|
}
|
||||||
|
return result.rows[0] as SummaryConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateConfig(updates: Partial<SummaryConfig>): Promise<SummaryConfig> {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (updates.weekend_suppression !== undefined) { fields.push(`weekend_suppression = $${idx++}`); values.push(updates.weekend_suppression); }
|
||||||
|
if (updates.monday_extended_window !== undefined) { fields.push(`monday_extended_window = $${idx++}`); values.push(updates.monday_extended_window); }
|
||||||
|
if (updates.severity_filter !== undefined) { fields.push(`severity_filter = $${idx++}`); values.push(updates.severity_filter); }
|
||||||
|
if (updates.outages_only !== undefined) { fields.push(`outages_only = $${idx++}`); values.push(updates.outages_only); }
|
||||||
|
|
||||||
|
if (fields.length === 0) return this.getConfig();
|
||||||
|
fields.push('updated_at = NOW()');
|
||||||
|
values.push(1);
|
||||||
|
|
||||||
|
const result = await postgresClient.query(
|
||||||
|
`UPDATE morning_summary_config SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return result.rows[0] as SummaryConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWebhooks(): Promise<WebhookConfig[]> {
|
||||||
|
const result = await postgresClient.query('SELECT * FROM morning_summary_webhooks ORDER BY id');
|
||||||
|
return result.rows as WebhookConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async createWebhook(label: string, webhookUrl: string): Promise<WebhookConfig> {
|
||||||
|
const result = await postgresClient.query(
|
||||||
|
'INSERT INTO morning_summary_webhooks (label, webhook_url, enabled) VALUES ($1, $2, true) RETURNING *',
|
||||||
|
[label, webhookUrl]
|
||||||
|
);
|
||||||
|
return result.rows[0] as WebhookConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateWebhook(id: number, updates: { label?: string; webhook_url?: string; enabled?: boolean }): Promise<WebhookConfig> {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (updates.label !== undefined) { fields.push(`label = $${idx++}`); values.push(updates.label); }
|
||||||
|
if (updates.webhook_url !== undefined) { fields.push(`webhook_url = $${idx++}`); values.push(updates.webhook_url); }
|
||||||
|
if (updates.enabled !== undefined) { fields.push(`enabled = $${idx++}`); values.push(updates.enabled); }
|
||||||
|
|
||||||
|
if (fields.length === 0) throw new Error('No fields to update');
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
const result = await postgresClient.query(
|
||||||
|
`UPDATE morning_summary_webhooks SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return result.rows[0] as WebhookConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteWebhook(id: number): Promise<void> {
|
||||||
|
await postgresClient.query('DELETE FROM morning_summary_webhooks WHERE id = $1', [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLatestSummaryRow(): Promise<{
|
||||||
|
id: number;
|
||||||
|
generated_at: string;
|
||||||
|
window_from: string;
|
||||||
|
window_to: string;
|
||||||
|
open_count: number;
|
||||||
|
resolved_count: number;
|
||||||
|
mttr_minutes: number | null;
|
||||||
|
clients_affected: string[];
|
||||||
|
is_weekend_window: boolean;
|
||||||
|
card_payload: object | null;
|
||||||
|
delivery_status: object;
|
||||||
|
} | null> {
|
||||||
|
const result = await postgresClient.query('SELECT * FROM morning_summaries ORDER BY generated_at DESC LIMIT 1');
|
||||||
|
return result.rows.length > 0 ? result.rows[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSummaryHistory(limit = 10): Promise<Array<{
|
||||||
|
id: number;
|
||||||
|
generated_at: string;
|
||||||
|
open_count: number;
|
||||||
|
resolved_count: number;
|
||||||
|
mttr_minutes: number | null;
|
||||||
|
is_weekend_window: boolean;
|
||||||
|
delivery_status: object;
|
||||||
|
}>> {
|
||||||
|
const result = await postgresClient.query(
|
||||||
|
'SELECT id, generated_at, open_count, resolved_count, mttr_minutes, is_weekend_window, delivery_status FROM morning_summaries ORDER BY generated_at DESC LIMIT $1',
|
||||||
|
[limit]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull data from Zabbix and build the MorningSummary + Adaptive Card.
|
||||||
|
*/
|
||||||
|
async aggregate(overrideWindowFrom?: Date): Promise<{ summary: MorningSummary; card: object }> {
|
||||||
|
const now = new Date();
|
||||||
|
const config = await this.getConfig();
|
||||||
|
const { from: windowFrom, isWeekend } = overrideWindowFrom
|
||||||
|
? { from: overrideWindowFrom, isWeekend: false }
|
||||||
|
: getWindowStart(now, config);
|
||||||
|
|
||||||
|
const zabbix = makeZabbix();
|
||||||
|
|
||||||
|
// Fetch open problems and resolved events in parallel
|
||||||
|
const [rawProblems, rawResolved] = await Promise.all([
|
||||||
|
zabbix.getOpenProblems(config.severity_filter),
|
||||||
|
zabbix.getResolvedEvents(windowFrom, now),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Build the set of unique triggerids from both result sets
|
||||||
|
const allTriggerIds = [
|
||||||
|
...new Set([
|
||||||
|
...rawProblems.map(p => p.objectid),
|
||||||
|
...rawResolved.map(e => e.objectid),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Resolve triggerid → { hostid, hostName } for ENABLED hosts only.
|
||||||
|
// Triggerids tied solely to disabled hosts will be absent from this map.
|
||||||
|
const triggerHostMap = await zabbix.getTriggerEnabledHosts(allTriggerIds);
|
||||||
|
|
||||||
|
// Fetch groups for every enabled hostid so we can derive clientName
|
||||||
|
const enabledHostIds = [...new Set([...triggerHostMap.values()].map(h => h.hostid))];
|
||||||
|
const groupMap = await zabbix.getHostGroupMap(enabledHostIds);
|
||||||
|
|
||||||
|
function resolveClient(triggerid: string): { hostid: string; hostName: string; clientName: string } {
|
||||||
|
const host = triggerHostMap.get(triggerid);
|
||||||
|
if (!host) return { hostid: '', hostName: 'Unknown', clientName: 'Unknown' };
|
||||||
|
const groups = groupMap.get(host.hostid) ?? [];
|
||||||
|
const clientGroup = groups.find((g: string) => g.startsWith('Clients/'));
|
||||||
|
const clientName = clientGroup ? clientGroup.replace('Clients/', '').trim() : host.hostName;
|
||||||
|
return { hostid: host.hostid, hostName: host.hostName, clientName };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only include problems whose trigger maps to an enabled host; optionally filter to outages only
|
||||||
|
const openProblems: MorningSummaryProblem[] = rawProblems
|
||||||
|
.filter(p => triggerHostMap.has(p.objectid))
|
||||||
|
.filter(p => !config.outages_only || p.name.toLowerCase().includes('unavailable'))
|
||||||
|
.map(p => {
|
||||||
|
const { hostid, hostName, clientName } = resolveClient(p.objectid);
|
||||||
|
const severity = parseInt(p.severity, 10);
|
||||||
|
const startedAt = new Date(parseInt(p.clock, 10) * 1000);
|
||||||
|
const durationMs = now.getTime() - startedAt.getTime();
|
||||||
|
const ackArray = Array.isArray(p.acknowledges) ? p.acknowledges : [];
|
||||||
|
const acknowledged = ackArray.length > 0;
|
||||||
|
const needsAttention = !acknowledged && durationMs > 4 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventid: p.eventid,
|
||||||
|
hostid,
|
||||||
|
hostName,
|
||||||
|
clientName,
|
||||||
|
triggerName: p.name,
|
||||||
|
severity,
|
||||||
|
severityLabel: SEVERITY_LABELS[severity] ?? 'Unknown',
|
||||||
|
startedAt,
|
||||||
|
durationLabel: formatDuration(durationMs),
|
||||||
|
acknowledged,
|
||||||
|
needsAttention,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort: needs attention first, then by severity desc, then by duration desc
|
||||||
|
openProblems.sort((a, b) => {
|
||||||
|
if (a.needsAttention !== b.needsAttention) return a.needsAttention ? -1 : 1;
|
||||||
|
if (b.severity !== a.severity) return b.severity - a.severity;
|
||||||
|
return b.startedAt.getTime() - a.startedAt.getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only include resolved events whose trigger maps to an enabled host; optionally filter to outages only
|
||||||
|
const resolvedOvernight: MorningSummaryResolved[] = rawResolved
|
||||||
|
.filter(ev => triggerHostMap.has(ev.objectid))
|
||||||
|
.filter(ev => !config.outages_only || ev.name.toLowerCase().includes('unavailable'))
|
||||||
|
.map(ev => {
|
||||||
|
const { hostName, clientName } = resolveClient(ev.objectid);
|
||||||
|
const startedAt = new Date(parseInt(ev.clock, 10) * 1000);
|
||||||
|
const resolvedAt = ev.r_clock ? new Date(parseInt(ev.r_clock, 10) * 1000) : now;
|
||||||
|
const durationMs = resolvedAt.getTime() - startedAt.getTime();
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventid: ev.eventid,
|
||||||
|
hostName,
|
||||||
|
clientName,
|
||||||
|
triggerName: ev.name,
|
||||||
|
startedAt,
|
||||||
|
resolvedAt,
|
||||||
|
durationLabel: formatDuration(durationMs),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const mttrMinutes = resolvedOvernight.length > 0
|
||||||
|
? Math.round(resolvedOvernight.reduce((sum, r) => {
|
||||||
|
return sum + (r.resolvedAt.getTime() - r.startedAt.getTime()) / 60000;
|
||||||
|
}, 0) / resolvedOvernight.length)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const clientsAffected = [...new Set(openProblems.map(p => p.clientName))].sort();
|
||||||
|
|
||||||
|
const summary: MorningSummary = {
|
||||||
|
generatedAt: now,
|
||||||
|
windowFrom,
|
||||||
|
windowTo: now,
|
||||||
|
isWeekendWindow: isWeekend,
|
||||||
|
openProblems,
|
||||||
|
resolvedOvernight,
|
||||||
|
openCount: openProblems.length,
|
||||||
|
resolvedCount: resolvedOvernight.length,
|
||||||
|
mttrMinutes,
|
||||||
|
clientsAffected,
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = buildAdaptiveCard(summary);
|
||||||
|
|
||||||
|
// Persist
|
||||||
|
await postgresClient.query(
|
||||||
|
`INSERT INTO morning_summaries
|
||||||
|
(generated_at, window_from, window_to, open_count, resolved_count, mttr_minutes,
|
||||||
|
clients_affected, is_weekend_window, card_payload, delivery_status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||||
|
[
|
||||||
|
now, windowFrom, now,
|
||||||
|
summary.openCount, summary.resolvedCount, summary.mttrMinutes,
|
||||||
|
summary.clientsAffected, summary.isWeekendWindow,
|
||||||
|
JSON.stringify(card), JSON.stringify({}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { summary, card };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post an Adaptive Card to one or more webhook URLs.
|
||||||
|
* Returns per-webhook results and updates DB delivery_status.
|
||||||
|
*/
|
||||||
|
async deliver(card: object, webhookIds?: number[]): Promise<DeliveryResult[]> {
|
||||||
|
const all = await this.getWebhooks();
|
||||||
|
const targets = webhookIds
|
||||||
|
? all.filter(w => webhookIds.includes(w.id))
|
||||||
|
: all.filter(w => w.enabled);
|
||||||
|
|
||||||
|
const envelope = {
|
||||||
|
type: 'message',
|
||||||
|
attachments: [{
|
||||||
|
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||||
|
contentUrl: null,
|
||||||
|
content: card,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
const results: DeliveryResult[] = await Promise.all(
|
||||||
|
targets.map(async (webhook): Promise<DeliveryResult> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(webhook.webhook_url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(envelope),
|
||||||
|
});
|
||||||
|
|
||||||
|
const success = res.ok;
|
||||||
|
const httpStatus = res.status;
|
||||||
|
|
||||||
|
await postgresClient.query(
|
||||||
|
`UPDATE morning_summary_webhooks
|
||||||
|
SET last_delivered_at = NOW(), last_status = $1 WHERE id = $2`,
|
||||||
|
[success ? 'success' : 'failed', webhook.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { webhookId: webhook.id, label: webhook.label, success, httpStatus };
|
||||||
|
} catch (err) {
|
||||||
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
|
await postgresClient.query(
|
||||||
|
`UPDATE morning_summary_webhooks SET last_delivered_at = NOW(), last_status = 'failed' WHERE id = $1`,
|
||||||
|
[webhook.id]
|
||||||
|
);
|
||||||
|
return { webhookId: webhook.id, label: webhook.label, success: false, error };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update delivery_status on the latest summary row
|
||||||
|
const statusMap: Record<number, object> = {};
|
||||||
|
for (const r of results) {
|
||||||
|
statusMap[r.webhookId] = { success: r.success, httpStatus: r.httpStatus, error: r.error };
|
||||||
|
}
|
||||||
|
await postgresClient.query(
|
||||||
|
`UPDATE morning_summaries SET delivery_status = $1
|
||||||
|
WHERE id = (SELECT id FROM morning_summaries ORDER BY generated_at DESC LIMIT 1)`,
|
||||||
|
[JSON.stringify(statusMap)]
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full run: aggregate + deliver. Used by scheduler and /send endpoint.
|
||||||
|
*/
|
||||||
|
async run(webhookIds?: number[]): Promise<{ summary: MorningSummary; results: DeliveryResult[] }> {
|
||||||
|
const { summary, card } = await this.aggregate();
|
||||||
|
const results = await this.deliver(card, webhookIds);
|
||||||
|
console.log(`[MORNING-SUMMARY] Open: ${summary.openCount}, Resolved: ${summary.resolvedCount}, Webhooks: ${results.length}`);
|
||||||
|
return { summary, results };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test send: aggregate current data and send to a single webhook only.
|
||||||
|
*/
|
||||||
|
async testSend(webhookId: number): Promise<DeliveryResult> {
|
||||||
|
const { card } = await this.aggregate();
|
||||||
|
const results = await this.deliver(card, [webhookId]);
|
||||||
|
return results[0] ?? { webhookId, label: '', success: false, error: 'Webhook not found' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _instance: MorningSummaryService | null = null;
|
||||||
|
export function getMorningSummaryService(): MorningSummaryService {
|
||||||
|
if (!_instance) _instance = new MorningSummaryService();
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
372
lib/services/msgraph-client.ts
Normal file
372
lib/services/msgraph-client.ts
Normal file
|
|
@ -0,0 +1,372 @@
|
||||||
|
/**
|
||||||
|
* Microsoft Graph API Client
|
||||||
|
* Client credentials flow for application permissions
|
||||||
|
* Reports.Read.All, User.Read.All, Calendars.Read
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface GraphUser {
|
||||||
|
id: string;
|
||||||
|
displayName: string | null;
|
||||||
|
mail: string | null;
|
||||||
|
userPrincipalName: string | null;
|
||||||
|
jobTitle: string | null;
|
||||||
|
department: string | null;
|
||||||
|
accountEnabled: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamsActivityRow {
|
||||||
|
userPrincipalName: string;
|
||||||
|
lastActivityDate: string;
|
||||||
|
teamChatMessageCount: number;
|
||||||
|
privateChatMessageCount: number;
|
||||||
|
callCount: number;
|
||||||
|
meetingCount: number;
|
||||||
|
meetingsOrganizedCount: number;
|
||||||
|
meetingsAttendedCount: number;
|
||||||
|
audioDurationSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailActivityRow {
|
||||||
|
userPrincipalName: string;
|
||||||
|
lastActivityDate: string;
|
||||||
|
sendCount: number;
|
||||||
|
receiveCount: number;
|
||||||
|
readCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserMessage {
|
||||||
|
createdDateTime: string;
|
||||||
|
fromUserId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarEvent {
|
||||||
|
id: string;
|
||||||
|
subject: string;
|
||||||
|
start: { dateTime: string; timeZone: string };
|
||||||
|
end: { dateTime: string; timeZone: string };
|
||||||
|
attendees: Array<{
|
||||||
|
emailAddress: { address: string; name: string };
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
isOnlineMeeting: boolean;
|
||||||
|
isCancelled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MsGraphClientConfig {
|
||||||
|
tenantId: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MsGraphClient {
|
||||||
|
private config: MsGraphClientConfig;
|
||||||
|
private accessToken: string | null = null;
|
||||||
|
private tokenExpiry: number = 0;
|
||||||
|
|
||||||
|
constructor(config: MsGraphClientConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getToken(): Promise<string> {
|
||||||
|
if (this.accessToken && Date.now() < this.tokenExpiry - 60000) {
|
||||||
|
return this.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://login.microsoftonline.com/${this.config.tenantId}/oauth2/v2.0/token`;
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
client_id: this.config.clientId,
|
||||||
|
client_secret: this.config.clientSecret,
|
||||||
|
scope: 'https://graph.microsoft.com/.default',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Graph token request failed: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
this.accessToken = data.access_token;
|
||||||
|
this.tokenExpiry = Date.now() + data.expires_in * 1000;
|
||||||
|
return this.accessToken!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchJson<T>(path: string, retryCount = 0): Promise<T> {
|
||||||
|
const token = await this.getToken();
|
||||||
|
const res = await fetch(`https://graph.microsoft.com/v1.0${path}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retry on 429 rate limit — respect Retry-After header (minimum 30s)
|
||||||
|
if (res.status === 429 && retryCount < 4) {
|
||||||
|
const retryAfter = Math.max(30, parseInt(res.headers.get('Retry-After') || '30', 10));
|
||||||
|
await new Promise(r => setTimeout(r, retryAfter * 1000));
|
||||||
|
return this.fetchJson(path, retryCount + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Graph API error ${res.status} for ${path}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchCsv(path: string): Promise<string> {
|
||||||
|
const token = await this.getToken();
|
||||||
|
const res = await fetch(`https://graph.microsoft.com/v1.0${path}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
Accept: 'text/csv',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Graph API error ${res.status} for ${path}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse CSV — handles BOM prefix on first header column
|
||||||
|
*/
|
||||||
|
private parseCsv(csv: string): Record<string, string>[] {
|
||||||
|
const lines = csv.split('\n').filter(l => l.trim());
|
||||||
|
if (lines.length < 2) return [];
|
||||||
|
|
||||||
|
// Strip BOM from first header if present
|
||||||
|
const rawHeaders = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, '').replace(/^\uFEFF/, ''));
|
||||||
|
const rows: Record<string, string>[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const values = lines[i].split(',').map(v => v.trim().replace(/^"|"$/g, ''));
|
||||||
|
const row: Record<string, string> = {};
|
||||||
|
rawHeaders.forEach((h, idx) => {
|
||||||
|
row[h] = values[idx] ?? '';
|
||||||
|
});
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseInt0(v: string): number {
|
||||||
|
const n = parseInt(v);
|
||||||
|
return isNaN(n) ? 0 : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all users in the tenant (paginated)
|
||||||
|
*/
|
||||||
|
async getUsers(): Promise<GraphUser[]> {
|
||||||
|
const users: GraphUser[] = [];
|
||||||
|
let url: string | null = '/users?$select=id,displayName,mail,userPrincipalName,jobTitle,department,accountEnabled&$top=999';
|
||||||
|
|
||||||
|
while (url) {
|
||||||
|
const data: { value: GraphUser[]; '@odata.nextLink'?: string } = await this.fetchJson(url);
|
||||||
|
users.push(...data.value);
|
||||||
|
if (data['@odata.nextLink']) {
|
||||||
|
url = data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '');
|
||||||
|
} else {
|
||||||
|
url = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the tenant's verified email domains (used to identify external attendees)
|
||||||
|
*/
|
||||||
|
async getOrganizationDomains(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const data = await this.fetchJson<{
|
||||||
|
value: Array<{ verifiedDomains: Array<{ name: string; isDefault: boolean }> }>;
|
||||||
|
}>('/organization?$select=verifiedDomains');
|
||||||
|
return (data.value[0]?.verifiedDomains ?? []).map(d => d.name.toLowerCase());
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Teams user activity report — includes audio duration in seconds
|
||||||
|
* period: 'D7' | 'D30' | 'D90'
|
||||||
|
*/
|
||||||
|
async getTeamsActivity(period: string): Promise<TeamsActivityRow[]> {
|
||||||
|
const csv = await this.fetchCsv(`/reports/getTeamsUserActivityUserDetail(period='${period}')`);
|
||||||
|
const rows = this.parseCsv(csv);
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.filter(r => r['User Principal Name'])
|
||||||
|
.map(r => ({
|
||||||
|
userPrincipalName: r['User Principal Name'] || '',
|
||||||
|
lastActivityDate: r['Last Activity Date'] || '',
|
||||||
|
teamChatMessageCount: this.parseInt0(r['Team Chat Message Count']),
|
||||||
|
privateChatMessageCount: this.parseInt0(r['Private Chat Message Count']),
|
||||||
|
callCount: this.parseInt0(r['Call Count']),
|
||||||
|
meetingCount: this.parseInt0(r['Meeting Count']),
|
||||||
|
meetingsOrganizedCount: this.parseInt0(r['Meetings Organized Count']),
|
||||||
|
meetingsAttendedCount: this.parseInt0(r['Meetings Attended Count']),
|
||||||
|
// "Audio Duration In Seconds" is the pre-computed seconds column
|
||||||
|
audioDurationSeconds: this.parseInt0(r['Audio Duration In Seconds']),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Email activity report
|
||||||
|
* period: 'D7' | 'D30' | 'D90'
|
||||||
|
*/
|
||||||
|
async getEmailActivity(period: string): Promise<EmailActivityRow[]> {
|
||||||
|
const csv = await this.fetchCsv(`/reports/getEmailActivityUserDetail(period='${period}')`);
|
||||||
|
const rows = this.parseCsv(csv);
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.filter(r => r['User Principal Name'])
|
||||||
|
.map(r => ({
|
||||||
|
userPrincipalName: r['User Principal Name'] || '',
|
||||||
|
lastActivityDate: r['Last Activity Date'] || '',
|
||||||
|
sendCount: this.parseInt0(r['Send Count']),
|
||||||
|
receiveCount: this.parseInt0(r['Receive Count']),
|
||||||
|
readCount: this.parseInt0(r['Read Count']),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get messages sent by a user in a date range, across all their chats.
|
||||||
|
* Requires Chat.Read.All application permission.
|
||||||
|
*
|
||||||
|
* Strategy (based on Graph API docs):
|
||||||
|
* 1. GET /users/{id}/chats with $expand=lastMessagePreview to find recently active chats.
|
||||||
|
* Stop paging when the chat's last message preview is older than startDate.
|
||||||
|
* 2. For each recent chat, GET /chats/{id}/messages with:
|
||||||
|
* $filter=lastModifiedDateTime gt {start} and lastModifiedDateTime lt {end}
|
||||||
|
* $orderby=lastModifiedDateTime desc (newest first)
|
||||||
|
*
|
||||||
|
* Returns only messages sent by this user (from.user.id match).
|
||||||
|
*/
|
||||||
|
async getUserMessages(
|
||||||
|
userId: string,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Promise<UserMessage[]> {
|
||||||
|
const messages: UserMessage[] = [];
|
||||||
|
const startIso = startDate.toISOString().slice(0, 19) + 'Z';
|
||||||
|
const endIso = endDate.toISOString().slice(0, 19) + 'Z';
|
||||||
|
const startMs = startDate.getTime();
|
||||||
|
|
||||||
|
// Step 1: get chats sorted by most recent activity, stop when too old
|
||||||
|
const chatIds: string[] = [];
|
||||||
|
let chatUrl: string | null =
|
||||||
|
`/users/${encodeURIComponent(userId)}/chats` +
|
||||||
|
`?$expand=lastMessagePreview&$orderby=lastMessagePreview/createdDateTime desc&$top=50`;
|
||||||
|
|
||||||
|
while (chatUrl && chatIds.length < 200) {
|
||||||
|
const data: {
|
||||||
|
value: Array<{ id: string; lastMessagePreview?: { createdDateTime?: string } }>;
|
||||||
|
'@odata.nextLink'?: string;
|
||||||
|
} = await this.fetchJson(chatUrl);
|
||||||
|
|
||||||
|
let hitOldChat = false;
|
||||||
|
for (const chat of data.value) {
|
||||||
|
const lastMsgTime = chat.lastMessagePreview?.createdDateTime
|
||||||
|
? new Date(chat.lastMessagePreview.createdDateTime).getTime()
|
||||||
|
: null;
|
||||||
|
if (lastMsgTime !== null && lastMsgTime < startMs) {
|
||||||
|
hitOldChat = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
chatIds.push(chat.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
chatUrl = hitOldChat
|
||||||
|
? null
|
||||||
|
: data['@odata.nextLink']
|
||||||
|
? data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '')
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: for each recent chat, get messages in the date range
|
||||||
|
for (const chatId of chatIds) {
|
||||||
|
let msgUrl: string | null =
|
||||||
|
`/chats/${encodeURIComponent(chatId)}/messages` +
|
||||||
|
`?$filter=lastModifiedDateTime gt ${startIso} and lastModifiedDateTime lt ${endIso}` +
|
||||||
|
`&$orderby=lastModifiedDateTime desc&$top=50`;
|
||||||
|
|
||||||
|
let pageCount = 0;
|
||||||
|
try {
|
||||||
|
while (msgUrl && pageCount < 20) {
|
||||||
|
const data: {
|
||||||
|
value: Array<{ createdDateTime: string; from?: { user?: { id: string } } }>;
|
||||||
|
'@odata.nextLink'?: string;
|
||||||
|
} = await this.fetchJson(msgUrl);
|
||||||
|
|
||||||
|
pageCount++;
|
||||||
|
for (const msg of data.value) {
|
||||||
|
if (msg.from?.user?.id !== userId) continue;
|
||||||
|
messages.push({ createdDateTime: msg.createdDateTime, fromUserId: userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
msgUrl = data['@odata.nextLink']
|
||||||
|
? data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '')
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip inaccessible chats (403 on meeting threads, 429 exhausted, etc.)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get calendar events for a user in a date range (paginated).
|
||||||
|
* Returns empty array and logs if the mailbox is not Exchange Online (graceful degradation).
|
||||||
|
*/
|
||||||
|
async getUserCalendarEvents(
|
||||||
|
userId: string,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Promise<CalendarEvent[]> {
|
||||||
|
const start = startDate.toISOString();
|
||||||
|
const end = endDate.toISOString();
|
||||||
|
const events: CalendarEvent[] = [];
|
||||||
|
|
||||||
|
let url: string | null =
|
||||||
|
`/users/${encodeURIComponent(userId)}/calendarView` +
|
||||||
|
`?startDateTime=${start}&endDateTime=${end}` +
|
||||||
|
`&$select=id,subject,start,end,attendees,isOnlineMeeting,isCancelled` +
|
||||||
|
`&$top=100`;
|
||||||
|
|
||||||
|
while (url) {
|
||||||
|
try {
|
||||||
|
const data: { value: CalendarEvent[]; '@odata.nextLink'?: string } =
|
||||||
|
await this.fetchJson(url);
|
||||||
|
events.push(...data.value.filter(e => !e.isCancelled));
|
||||||
|
url = data['@odata.nextLink']
|
||||||
|
? data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '')
|
||||||
|
: null;
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
// Guests and on-prem mailboxes don't support REST API — skip silently
|
||||||
|
if (msg.includes('MailboxNotEnabledForRESTAPI') || msg.includes('404')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
lib/services/msgraph-factory.ts
Normal file
42
lib/services/msgraph-factory.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { MsGraphClient, MsGraphClientConfig } from './msgraph-client';
|
||||||
|
|
||||||
|
let msGraphClientInstance: MsGraphClient | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Microsoft Graph credentials are configured
|
||||||
|
*/
|
||||||
|
export function isMsgraphConfigured(): boolean {
|
||||||
|
return !!(
|
||||||
|
process.env.MSGRAPH_CLIENT_ID &&
|
||||||
|
process.env.MSGRAPH_CLIENT_SECRET &&
|
||||||
|
process.env.MSGRAPH_TENANT_ID
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create Microsoft Graph client singleton
|
||||||
|
*/
|
||||||
|
export function getMsgraphClient(): MsGraphClient {
|
||||||
|
if (!msGraphClientInstance) {
|
||||||
|
const config: MsGraphClientConfig = {
|
||||||
|
tenantId: process.env.MSGRAPH_TENANT_ID || '',
|
||||||
|
clientId: process.env.MSGRAPH_CLIENT_ID || '',
|
||||||
|
clientSecret: process.env.MSGRAPH_CLIENT_SECRET || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!config.tenantId || !config.clientId || !config.clientSecret) {
|
||||||
|
throw new Error(
|
||||||
|
'Microsoft Graph credentials missing. Set MSGRAPH_CLIENT_ID, MSGRAPH_CLIENT_SECRET, and MSGRAPH_TENANT_ID.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
msGraphClientInstance = new MsGraphClient(config);
|
||||||
|
console.log('[MSGRAPH] Client initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
return msGraphClientInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetMsgraphClient(): void {
|
||||||
|
msGraphClientInstance = null;
|
||||||
|
}
|
||||||
|
|
@ -7,15 +7,21 @@ import cron, { ScheduledTask } from 'node-cron';
|
||||||
import { SyncService, createSyncService } from './sync-service';
|
import { SyncService, createSyncService } from './sync-service';
|
||||||
import { postgresClient } from './postgres-client';
|
import { postgresClient } from './postgres-client';
|
||||||
import { AutotaskClient } from './autotask-client';
|
import { AutotaskClient } from './autotask-client';
|
||||||
|
import { EntityType, SyncType } from '../types/sync';
|
||||||
import { VeeamSyncService } from './veeam-sync-service';
|
import { VeeamSyncService } from './veeam-sync-service';
|
||||||
import { VeeamRpoService } from './veeam-rpo-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 {
|
export interface ScheduleConfig {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
cron_expression: 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;
|
years_back?: number;
|
||||||
is_enabled: boolean;
|
is_enabled: boolean;
|
||||||
last_run?: Date;
|
last_run?: Date;
|
||||||
|
|
@ -40,6 +46,8 @@ class SyncScheduler {
|
||||||
private syncService: SyncService;
|
private syncService: SyncService;
|
||||||
private _veeamSyncService: VeeamSyncService | null = null;
|
private _veeamSyncService: VeeamSyncService | null = null;
|
||||||
private _veeamRpoService: VeeamRpoService | null = null;
|
private _veeamRpoService: VeeamRpoService | null = null;
|
||||||
|
private _engagementSyncService: EngagementSyncService | null = null;
|
||||||
|
private _zoomSyncService: ZoomSyncService | null = null;
|
||||||
|
|
||||||
private getVeeamSyncService(): VeeamSyncService {
|
private getVeeamSyncService(): VeeamSyncService {
|
||||||
if (!this._veeamSyncService) {
|
if (!this._veeamSyncService) {
|
||||||
|
|
@ -55,6 +63,28 @@ class SyncScheduler {
|
||||||
return this._veeamRpoService;
|
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() {
|
constructor() {
|
||||||
// Create sync service instance
|
// Create sync service instance
|
||||||
const autotaskClient = new AutotaskClient({
|
const autotaskClient = new AutotaskClient({
|
||||||
|
|
@ -105,7 +135,7 @@ class SyncScheduler {
|
||||||
name VARCHAR(100) NOT NULL,
|
name VARCHAR(100) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
cron_expression VARCHAR(50) NOT NULL,
|
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,
|
years_back INTEGER DEFAULT 2,
|
||||||
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
last_run TIMESTAMP,
|
last_run TIMESTAMP,
|
||||||
|
|
@ -180,6 +210,38 @@ class SyncScheduler {
|
||||||
sync_type: 'veeam-rpo-check',
|
sync_type: 'veeam-rpo-check',
|
||||||
is_enabled: false,
|
is_enabled: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'contract-services',
|
||||||
|
name: 'Contract Services Sync',
|
||||||
|
description: 'Syncs Autotask contract service lines (service catalog items per contract) daily at 4 AM',
|
||||||
|
cron_expression: '0 4 * * *',
|
||||||
|
sync_type: 'contract-services',
|
||||||
|
is_enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'engagement-daily',
|
||||||
|
name: 'Engagement Daily Sync',
|
||||||
|
description: 'Syncs Microsoft Graph Teams and email activity for employee engagement dashboard daily at 6 AM',
|
||||||
|
cron_expression: '0 6 * * *',
|
||||||
|
sync_type: 'engagement-daily',
|
||||||
|
is_enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zoom-daily',
|
||||||
|
name: 'Zoom Daily Sync',
|
||||||
|
description: 'Syncs Zoom Phone call logs and meeting data daily at 6 AM',
|
||||||
|
cron_expression: '0 6 * * *',
|
||||||
|
sync_type: 'zoom-daily',
|
||||||
|
is_enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'morning-summary',
|
||||||
|
name: 'Morning NOC Summary',
|
||||||
|
description: 'Posts a Zabbix overnight summary Adaptive Card to configured Teams channel webhooks at 6:30 AM Mon–Fri',
|
||||||
|
cron_expression: '30 6 * * 1-5',
|
||||||
|
sync_type: 'morning-summary',
|
||||||
|
is_enabled: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const schedule of defaultSchedules) {
|
for (const schedule of defaultSchedules) {
|
||||||
|
|
@ -289,6 +351,22 @@ class SyncScheduler {
|
||||||
await this.getVeeamSyncService().fullSync('scheduled');
|
await this.getVeeamSyncService().fullSync('scheduled');
|
||||||
} else if (config.sync_type === 'veeam-rpo-check') {
|
} else if (config.sync_type === 'veeam-rpo-check') {
|
||||||
await this.getVeeamRpoService().runCheck();
|
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') {
|
} else if (config.sync_type === 'incremental') {
|
||||||
await this.syncService.incrementalSync('scheduled');
|
await this.syncService.incrementalSync('scheduled');
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import {
|
||||||
ZabbixHostCreateParams,
|
ZabbixHostCreateParams,
|
||||||
ZabbixHostUpdateParams,
|
ZabbixHostUpdateParams,
|
||||||
ZabbixRpcResponse,
|
ZabbixRpcResponse,
|
||||||
|
ZabbixProblem,
|
||||||
|
ZabbixEvent,
|
||||||
} from '@/lib/types/zabbix';
|
} from '@/lib/types/zabbix';
|
||||||
|
|
||||||
export type { ZabbixHostTag } from '@/lib/types/zabbix';
|
export type { ZabbixHostTag } from '@/lib/types/zabbix';
|
||||||
|
|
@ -116,6 +118,119 @@ export class ZabbixClient {
|
||||||
return null;
|
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.
|
* Create or update a Zabbix host. Idempotent — looks up by host name first.
|
||||||
* Returns the hostid and whether the host was created or updated.
|
* Returns the hostid and whether the host was created or updated.
|
||||||
|
|
|
||||||
206
lib/services/zabbix-wan-utils.ts
Normal file
206
lib/services/zabbix-wan-utils.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
/**
|
||||||
|
* Shared utilities for Zabbix WAN host management.
|
||||||
|
* Used by both the RMM discovery sync and the manual host creation endpoints.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ZabbixClient } from '@/lib/services/zabbix-client';
|
||||||
|
import { ZabbixHostMacro, ZabbixHostTag, ZabbixHostCreateParams } from '@/lib/types/zabbix';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ISP info type
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface IspInfo {
|
||||||
|
isp: string; // "Comcast Cable Communications, LLC"
|
||||||
|
asn: string; // "AS7922"
|
||||||
|
city: string;
|
||||||
|
region: string;
|
||||||
|
country: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Zabbix host technical name sanitization
|
||||||
|
// Zabbix rejects: + ' , . & ( ) and other special chars in the `host` field.
|
||||||
|
// We sanitize to alphanumeric, spaces, hyphens, underscores only.
|
||||||
|
// The display `name` field is left as-is (accepts any UTF-8).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function sanitizeHostname(name: string): string {
|
||||||
|
return name
|
||||||
|
.replace(/[^a-zA-Z0-9 \-_]/g, '') // strip disallowed chars
|
||||||
|
.replace(/\s+/g, ' ') // collapse multiple spaces
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ISP lookup via ipinfo.io (free, no key required for basic fields)
|
||||||
|
// Results are cached within the module lifetime to avoid duplicate lookups.
|
||||||
|
// Call clearIspCache() at the start of each request if needed.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ispCache = new Map<string, IspInfo | null>();
|
||||||
|
|
||||||
|
export function clearIspCache(): void {
|
||||||
|
ispCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lookupIsp(ip: string): Promise<IspInfo | null> {
|
||||||
|
if (ispCache.has(ip)) return ispCache.get(ip)!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = process.env.IPINFO_TOKEN;
|
||||||
|
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const res = await fetch(`https://ipinfo.io/${ip}/json`, {
|
||||||
|
headers,
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: AbortSignal.timeout(6000),
|
||||||
|
});
|
||||||
|
if (!res.ok) { ispCache.set(ip, null); return null; }
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
// org field format: "AS7922 Comcast Cable Communications, LLC"
|
||||||
|
const org: string = data.org ?? '';
|
||||||
|
const m = org.match(/^(AS\d+)\s+(.+)$/);
|
||||||
|
|
||||||
|
const info: IspInfo = {
|
||||||
|
isp: m ? m[2] : org,
|
||||||
|
asn: m ? m[1] : '',
|
||||||
|
city: data.city ?? '',
|
||||||
|
region: data.region ?? '',
|
||||||
|
country: data.country ?? '',
|
||||||
|
};
|
||||||
|
ispCache.set(ip, info);
|
||||||
|
return info;
|
||||||
|
} catch {
|
||||||
|
ispCache.set(ip, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Build the full Zabbix host upsert params from common inputs.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface BuildHostParamsInput {
|
||||||
|
siteName: string;
|
||||||
|
wanIp: string;
|
||||||
|
companyId?: number;
|
||||||
|
companyName?: string;
|
||||||
|
rmmSiteUid?: string;
|
||||||
|
ispInfo: IspInfo | null;
|
||||||
|
multiWan?: boolean;
|
||||||
|
allIps?: string[];
|
||||||
|
singleDeviceFallback?: boolean;
|
||||||
|
onlineDeviceCount?: number;
|
||||||
|
source?: string; // tag value for "source" (default "datto-rmm")
|
||||||
|
icmpTemplateId: string | null;
|
||||||
|
globalGroupId: string;
|
||||||
|
zabbix: ZabbixClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildHostParams(input: BuildHostParamsInput): Promise<ZabbixHostCreateParams> {
|
||||||
|
const {
|
||||||
|
siteName, wanIp, companyId, companyName, rmmSiteUid,
|
||||||
|
ispInfo, multiWan, allIps, singleDeviceFallback,
|
||||||
|
onlineDeviceCount, source, icmpTemplateId, globalGroupId, zabbix,
|
||||||
|
} = input;
|
||||||
|
|
||||||
|
const templates = icmpTemplateId ? [{ templateid: icmpTemplateId }] : undefined;
|
||||||
|
|
||||||
|
// Build groups: always global, + per-client, + per-ISP
|
||||||
|
const groups: Array<{ groupid: string }> = [{ groupid: globalGroupId }];
|
||||||
|
|
||||||
|
if (companyName) {
|
||||||
|
const clientGroupId = await zabbix.ensureHostGroup(`Clients/${companyName}`);
|
||||||
|
groups.push({ groupid: clientGroupId });
|
||||||
|
}
|
||||||
|
if (ispInfo?.isp) {
|
||||||
|
const ispGroupId = await zabbix.ensureHostGroup(`ISP/${ispInfo.isp}`);
|
||||||
|
groups.push({ groupid: ispGroupId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build macros: Autotask identity + ISP context
|
||||||
|
const macros: ZabbixHostMacro[] = [];
|
||||||
|
if (companyId && companyName) {
|
||||||
|
macros.push(
|
||||||
|
{ macro: '{$AUTOTASK_COMPANY_ID}', value: String(companyId), description: 'Autotask company ID' },
|
||||||
|
{ macro: '{$AUTOTASK_COMPANY_NAME}', value: companyName, description: 'Autotask company name' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rmmSiteUid) {
|
||||||
|
macros.push(
|
||||||
|
{ macro: '{$RMM_SITE_UID}', value: rmmSiteUid, description: 'Datto RMM site UID' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ispInfo) {
|
||||||
|
macros.push(
|
||||||
|
{ macro: '{$ISP_NAME}', value: ispInfo.isp, description: 'ISP / carrier name' },
|
||||||
|
{ macro: '{$ASN}', value: ispInfo.asn, description: 'Autonomous System Number' },
|
||||||
|
{ macro: '{$ISP_CITY}', value: ispInfo.city, description: 'City (from IP geolocation)' },
|
||||||
|
{ macro: '{$ISP_REGION}', value: ispInfo.region, description: 'Region (from IP geolocation)' },
|
||||||
|
{ macro: '{$ISP_COUNTRY}', value: ispInfo.country, description: 'Country code (from IP geolocation)' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (multiWan && allIps) {
|
||||||
|
macros.push({ macro: '{$MULTI_WAN_IPS}', value: allIps.join(', '), description: 'All public IPs seen (multi-WAN site)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build tags: for dashboard filtering and problem correlation
|
||||||
|
const sourceTag = source ?? 'datto-rmm';
|
||||||
|
const tags: ZabbixHostTag[] = [{ tag: 'source', value: sourceTag }];
|
||||||
|
if (companyName) {
|
||||||
|
tags.push({ tag: 'client', value: companyName });
|
||||||
|
}
|
||||||
|
if (ispInfo?.isp) {
|
||||||
|
tags.push({ tag: 'isp', value: ispInfo.isp });
|
||||||
|
}
|
||||||
|
if (ispInfo?.asn) {
|
||||||
|
tags.push({ tag: 'asn', value: ispInfo.asn });
|
||||||
|
}
|
||||||
|
if (multiWan) {
|
||||||
|
tags.push({ tag: 'multi-wan', value: 'true' });
|
||||||
|
}
|
||||||
|
if (singleDeviceFallback) {
|
||||||
|
tags.push({ tag: 'single-device-fallback', value: 'true' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const descParts = [
|
||||||
|
onlineDeviceCount != null
|
||||||
|
? `Datto RMM site – WAN IP from ${onlineDeviceCount} online devices`
|
||||||
|
: `Manual host – WAN IP ${wanIp}`,
|
||||||
|
ispInfo ? `ISP: ${ispInfo.isp} (${ispInfo.asn}) — ${ispInfo.city}, ${ispInfo.region}, ${ispInfo.country}` : null,
|
||||||
|
multiWan && allIps ? `Multi-WAN detected: ${allIps.join(', ')}` : null,
|
||||||
|
singleDeviceFallback ? `Note: IP sourced from single device (no multi-device confirmation)` : null,
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
host: sanitizeHostname(siteName),
|
||||||
|
name: siteName,
|
||||||
|
description: descParts,
|
||||||
|
interfaces: [{ type: 1, main: 1, useip: 1, ip: wanIp, dns: '', port: '10050' }],
|
||||||
|
groups,
|
||||||
|
templates,
|
||||||
|
macros: macros.length > 0 ? macros : undefined,
|
||||||
|
tags,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Discover ICMP template — tries multiple common names
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ICMP_TEMPLATE_NAMES = [
|
||||||
|
'ICMP Ping',
|
||||||
|
'Template Module ICMP Ping',
|
||||||
|
'Template Module ICMP Ping by Zabbix agent',
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function discoverIcmpTemplate(zabbix: ZabbixClient): Promise<string | null> {
|
||||||
|
for (const name of ICMP_TEMPLATE_NAMES) {
|
||||||
|
const tmpl = await zabbix.findTemplate(name);
|
||||||
|
if (tmpl) return tmpl.templateid;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
264
lib/services/zoom-client.ts
Normal file
264
lib/services/zoom-client.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
/**
|
||||||
|
* Zoom Server-to-Server OAuth API Client
|
||||||
|
* Requires: ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ZoomUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
display_name: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZoomCallLog {
|
||||||
|
id: string;
|
||||||
|
call_id: string;
|
||||||
|
caller_number: string;
|
||||||
|
caller_name: string;
|
||||||
|
callee_number: string;
|
||||||
|
callee_name: string;
|
||||||
|
direction: 'inbound' | 'outbound' | 'internal';
|
||||||
|
result: string; // 'Call connected', 'Missed', 'Voicemail', etc.
|
||||||
|
date_time: string; // ISO 8601 — Zoom Phone API uses date_time, not start_time
|
||||||
|
duration: number; // seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZoomMeeting {
|
||||||
|
id: string | number;
|
||||||
|
uuid: string;
|
||||||
|
host_id: string;
|
||||||
|
host_email?: string;
|
||||||
|
topic: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time?: string;
|
||||||
|
duration: number; // minutes
|
||||||
|
participants_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZoomMeetingParticipant {
|
||||||
|
id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
name: string;
|
||||||
|
user_email: string;
|
||||||
|
duration: number; // seconds
|
||||||
|
join_time: string;
|
||||||
|
leave_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZoomClientConfig {
|
||||||
|
accountId: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ZoomClient {
|
||||||
|
private config: ZoomClientConfig;
|
||||||
|
private accessToken: string | null = null;
|
||||||
|
private tokenExpiry: number = 0;
|
||||||
|
private readonly baseUrl = 'https://api.zoom.us/v2';
|
||||||
|
|
||||||
|
constructor(config: ZoomClientConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getToken(): Promise<string> {
|
||||||
|
// Refresh 5 minutes before expiry
|
||||||
|
if (this.accessToken && Date.now() < this.tokenExpiry - 300000) {
|
||||||
|
return this.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = Buffer.from(
|
||||||
|
`${this.config.clientId}:${this.config.clientSecret}`
|
||||||
|
).toString('base64');
|
||||||
|
|
||||||
|
const url = `https://zoom.us/oauth/token?grant_type=account_credentials&account_id=${encodeURIComponent(this.config.accountId)}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${credentials}`,
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Zoom token request failed: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
this.accessToken = data.access_token;
|
||||||
|
this.tokenExpiry = Date.now() + data.expires_in * 1000;
|
||||||
|
return this.accessToken!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchJson<T>(path: string, retries = 0): Promise<T> {
|
||||||
|
const token = await this.getToken();
|
||||||
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 429) {
|
||||||
|
if (retries >= 5) {
|
||||||
|
throw new Error(`Zoom rate limit hit after ${retries} retries: ${path}`);
|
||||||
|
}
|
||||||
|
const delay = Math.pow(2, retries) * 1000;
|
||||||
|
console.warn(`[ZOOM] Rate limited on ${path}, retrying in ${delay}ms`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
return this.fetchJson<T>(path, retries + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Zoom API error ${res.status} for ${path}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active Zoom users (paginated via next_page_token)
|
||||||
|
*/
|
||||||
|
async getUsers(): Promise<ZoomUser[]> {
|
||||||
|
const users: ZoomUser[] = [];
|
||||||
|
let nextPageToken = '';
|
||||||
|
|
||||||
|
do {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
status: 'active',
|
||||||
|
page_size: '300',
|
||||||
|
});
|
||||||
|
if (nextPageToken) params.set('next_page_token', nextPageToken);
|
||||||
|
|
||||||
|
const data = await this.fetchJson<{
|
||||||
|
users: ZoomUser[];
|
||||||
|
next_page_token?: string;
|
||||||
|
}>(`/users?${params}`);
|
||||||
|
|
||||||
|
users.push(...(data.users ?? []));
|
||||||
|
nextPageToken = data.next_page_token ?? '';
|
||||||
|
} while (nextPageToken);
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get phone call logs for a user in a date range (paginated)
|
||||||
|
* from/to: ISO date strings 'YYYY-MM-DD'
|
||||||
|
*/
|
||||||
|
async getUserCallLogs(
|
||||||
|
zoomUserId: string,
|
||||||
|
from: string,
|
||||||
|
to: string
|
||||||
|
): Promise<ZoomCallLog[]> {
|
||||||
|
const calls: ZoomCallLog[] = [];
|
||||||
|
let nextPageToken = '';
|
||||||
|
|
||||||
|
do {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
page_size: '300',
|
||||||
|
type: 'all',
|
||||||
|
});
|
||||||
|
if (nextPageToken) params.set('next_page_token', nextPageToken);
|
||||||
|
|
||||||
|
let data: { call_logs?: ZoomCallLog[]; next_page_token?: string };
|
||||||
|
try {
|
||||||
|
data = await this.fetchJson<{
|
||||||
|
call_logs?: ZoomCallLog[];
|
||||||
|
next_page_token?: string;
|
||||||
|
}>(`/phone/users/${encodeURIComponent(zoomUserId)}/call_logs?${params}`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
// 400 = user has no Zoom Phone license — skip silently
|
||||||
|
if (msg.includes('400') || msg.includes('404')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
calls.push(...(data.call_logs ?? []));
|
||||||
|
nextPageToken = data.next_page_token ?? '';
|
||||||
|
} while (nextPageToken);
|
||||||
|
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get past meetings for a user in a date range (paginated)
|
||||||
|
* Uses the Reports API — requires role or admin scope
|
||||||
|
*/
|
||||||
|
async getUserPastMeetings(
|
||||||
|
zoomUserId: string,
|
||||||
|
from: string,
|
||||||
|
to: string
|
||||||
|
): Promise<ZoomMeeting[]> {
|
||||||
|
const meetings: ZoomMeeting[] = [];
|
||||||
|
let nextPageToken = '';
|
||||||
|
|
||||||
|
do {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
type: 'past',
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
page_size: '300',
|
||||||
|
});
|
||||||
|
if (nextPageToken) params.set('next_page_token', nextPageToken);
|
||||||
|
|
||||||
|
let data: { meetings?: ZoomMeeting[]; next_page_token?: string };
|
||||||
|
try {
|
||||||
|
data = await this.fetchJson<{
|
||||||
|
meetings?: ZoomMeeting[];
|
||||||
|
next_page_token?: string;
|
||||||
|
}>(`/report/users/${encodeURIComponent(zoomUserId)}/meetings?${params}`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (msg.includes('400') || msg.includes('404')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
meetings.push(...(data.meetings ?? []));
|
||||||
|
nextPageToken = data.next_page_token ?? '';
|
||||||
|
} while (nextPageToken);
|
||||||
|
|
||||||
|
return meetings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get participants for a past meeting (Zoom limitation: last 30 days only)
|
||||||
|
*/
|
||||||
|
async getMeetingParticipants(meetingId: string): Promise<ZoomMeetingParticipant[]> {
|
||||||
|
const participants: ZoomMeetingParticipant[] = [];
|
||||||
|
let nextPageToken = '';
|
||||||
|
|
||||||
|
do {
|
||||||
|
const params = new URLSearchParams({ page_size: '300' });
|
||||||
|
if (nextPageToken) params.set('next_page_token', nextPageToken);
|
||||||
|
|
||||||
|
let data: { participants?: ZoomMeetingParticipant[]; next_page_token?: string };
|
||||||
|
try {
|
||||||
|
data = await this.fetchJson<{
|
||||||
|
participants?: ZoomMeetingParticipant[];
|
||||||
|
next_page_token?: string;
|
||||||
|
}>(`/past_meetings/${encodeURIComponent(meetingId)}/participants?${params}`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (msg.includes('400') || msg.includes('404') || msg.includes('3001')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
participants.push(...(data.participants ?? []));
|
||||||
|
nextPageToken = data.next_page_token ?? '';
|
||||||
|
} while (nextPageToken);
|
||||||
|
|
||||||
|
return participants;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
lib/services/zoom-factory.ts
Normal file
42
lib/services/zoom-factory.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { ZoomClient, ZoomClientConfig } from './zoom-client';
|
||||||
|
|
||||||
|
let zoomClientInstance: ZoomClient | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Zoom Server-to-Server OAuth credentials are configured
|
||||||
|
*/
|
||||||
|
export function isZoomConfigured(): boolean {
|
||||||
|
return !!(
|
||||||
|
process.env.ZOOM_ACCOUNT_ID &&
|
||||||
|
process.env.ZOOM_CLIENT_ID &&
|
||||||
|
process.env.ZOOM_CLIENT_SECRET
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create Zoom client singleton
|
||||||
|
*/
|
||||||
|
export function getZoomClient(): ZoomClient {
|
||||||
|
if (!zoomClientInstance) {
|
||||||
|
const config: ZoomClientConfig = {
|
||||||
|
accountId: process.env.ZOOM_ACCOUNT_ID || '',
|
||||||
|
clientId: process.env.ZOOM_CLIENT_ID || '',
|
||||||
|
clientSecret: process.env.ZOOM_CLIENT_SECRET || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!config.accountId || !config.clientId || !config.clientSecret) {
|
||||||
|
throw new Error(
|
||||||
|
'Zoom credentials missing. Set ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, and ZOOM_CLIENT_SECRET.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomClientInstance = new ZoomClient(config);
|
||||||
|
console.log('[ZOOM] Client initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
return zoomClientInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetZoomClient(): void {
|
||||||
|
zoomClientInstance = null;
|
||||||
|
}
|
||||||
387
lib/services/zoom-sync-service.ts
Normal file
387
lib/services/zoom-sync-service.ts
Normal file
|
|
@ -0,0 +1,387 @@
|
||||||
|
/**
|
||||||
|
* Zoom Sync Service
|
||||||
|
* Orchestrates Zoom Phone + Meetings → PostgreSQL sync
|
||||||
|
* Cross-references calls/meetings with Autotask contacts and companies
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getZoomClient } from './zoom-factory';
|
||||||
|
import { postgresClient } from './postgres-client';
|
||||||
|
|
||||||
|
const CALL_WINDOW_DAYS = 90;
|
||||||
|
const MEETING_WINDOW_DAYS = 90;
|
||||||
|
const PARTICIPANT_WINDOW_DAYS = 30; // Zoom API limitation
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip all non-digit characters and return last 10 digits.
|
||||||
|
* Handles country code variations (e.g. +1 prefix).
|
||||||
|
*/
|
||||||
|
function normalizePhone(phone: string | null | undefined): string {
|
||||||
|
if (!phone) return '';
|
||||||
|
const digits = phone.replace(/\D/g, '');
|
||||||
|
return digits.slice(-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIsoDate(d: Date): string {
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ZoomSyncService {
|
||||||
|
private syncInProgress = false;
|
||||||
|
|
||||||
|
isSyncInProgress(): boolean {
|
||||||
|
return this.syncInProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sync(): Promise<{
|
||||||
|
usersUpserted: number;
|
||||||
|
callsUpserted: number;
|
||||||
|
callsMatched: number;
|
||||||
|
meetingsUpserted: number;
|
||||||
|
participantsUpserted: number;
|
||||||
|
}> {
|
||||||
|
if (this.syncInProgress) {
|
||||||
|
throw new Error('Zoom sync already in progress');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncInProgress = true;
|
||||||
|
const startTime = Date.now();
|
||||||
|
console.log('[ZOOM-SYNC] Starting sync...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = getZoomClient();
|
||||||
|
|
||||||
|
// ─── Step 1: Sync Zoom users ───────────────────────────────────────
|
||||||
|
console.log('[ZOOM-SYNC] Fetching Zoom users...');
|
||||||
|
const zoomUsers = await client.getUsers();
|
||||||
|
|
||||||
|
// Load set of resource emails from DB (for filtering)
|
||||||
|
const resourceEmailsResult = await postgresClient.query(
|
||||||
|
`SELECT LOWER(email) as email FROM resources
|
||||||
|
WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL`
|
||||||
|
);
|
||||||
|
const resourceEmails = new Set<string>(
|
||||||
|
resourceEmailsResult.rows.map((r: { email: string }) => r.email)
|
||||||
|
);
|
||||||
|
|
||||||
|
let usersUpserted = 0;
|
||||||
|
const activeZoomUsers = zoomUsers.filter(
|
||||||
|
u => u.email && resourceEmails.has(u.email.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const user of activeZoomUsers) {
|
||||||
|
await postgresClient.query(
|
||||||
|
`INSERT INTO zoom_users (zoom_id, email, display_name, is_active, synced_at)
|
||||||
|
VALUES ($1, $2, $3, true, NOW())
|
||||||
|
ON CONFLICT (zoom_id) DO UPDATE SET
|
||||||
|
email = EXCLUDED.email,
|
||||||
|
display_name = EXCLUDED.display_name,
|
||||||
|
is_active = true,
|
||||||
|
synced_at = NOW()`,
|
||||||
|
[user.id, user.email.toLowerCase(), user.display_name]
|
||||||
|
);
|
||||||
|
usersUpserted++;
|
||||||
|
}
|
||||||
|
console.log(`[ZOOM-SYNC] Upserted ${usersUpserted} Zoom users (filtered to resource emails)`);
|
||||||
|
|
||||||
|
// ─── Step 2: Build phone match index from Autotask contacts ───────
|
||||||
|
console.log('[ZOOM-SYNC] Building phone index from Autotask contacts...');
|
||||||
|
|
||||||
|
const contactsResult = await postgresClient.query(
|
||||||
|
`SELECT id, company_id, phone, mobile_phone, alternate_phone
|
||||||
|
FROM contacts
|
||||||
|
WHERE (is_deleted = false OR is_deleted IS NULL)`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map: normalized 10-digit phone → { contactId, companyId }
|
||||||
|
const phoneIndex = new Map<string, { contactId: number; companyId: number | null }>();
|
||||||
|
for (const row of contactsResult.rows) {
|
||||||
|
for (const field of ['phone', 'mobile_phone', 'alternate_phone'] as const) {
|
||||||
|
const norm = normalizePhone(row[field]);
|
||||||
|
if (norm && !phoneIndex.has(norm)) {
|
||||||
|
phoneIndex.set(norm, { contactId: row.id, companyId: row.company_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also index company phones → companyId (fallback when no contact match)
|
||||||
|
const companiesResult = await postgresClient.query(
|
||||||
|
`SELECT id, phone FROM companies WHERE phone IS NOT NULL`
|
||||||
|
);
|
||||||
|
const companyPhoneIndex = new Map<string, number>();
|
||||||
|
for (const row of companiesResult.rows) {
|
||||||
|
const norm = normalizePhone(row.phone);
|
||||||
|
if (norm && !companyPhoneIndex.has(norm)) {
|
||||||
|
companyPhoneIndex.set(norm, row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal phone set — to skip contact matching for internal calls
|
||||||
|
const internalNumbers = new Set<string>();
|
||||||
|
|
||||||
|
// ─── Step 3: Sync call logs (90-day window) ────────────────────────
|
||||||
|
const callFrom = toIsoDate(new Date(Date.now() - CALL_WINDOW_DAYS * 24 * 60 * 60 * 1000));
|
||||||
|
const callTo = toIsoDate(new Date());
|
||||||
|
|
||||||
|
let callsUpserted = 0;
|
||||||
|
let callsMatched = 0;
|
||||||
|
|
||||||
|
for (const user of activeZoomUsers) {
|
||||||
|
console.log(`[ZOOM-SYNC] Fetching call logs for ${user.email}...`);
|
||||||
|
const calls = await client.getUserCallLogs(user.id, callFrom, callTo);
|
||||||
|
|
||||||
|
for (const call of calls) {
|
||||||
|
// Determine direction and the other-party number
|
||||||
|
const direction = call.direction;
|
||||||
|
let otherPartyNumber: string;
|
||||||
|
let otherPartyName: string;
|
||||||
|
|
||||||
|
if (direction === 'outbound') {
|
||||||
|
otherPartyNumber = call.callee_number;
|
||||||
|
otherPartyName = call.callee_name || '';
|
||||||
|
} else if (direction === 'inbound') {
|
||||||
|
otherPartyNumber = call.caller_number;
|
||||||
|
otherPartyName = call.caller_name || '';
|
||||||
|
} else {
|
||||||
|
// internal — record but skip contact matching
|
||||||
|
otherPartyNumber = call.caller_number || call.callee_number || '';
|
||||||
|
otherPartyName = call.caller_name || call.callee_name || '';
|
||||||
|
internalNumbers.add(normalizePhone(otherPartyNumber));
|
||||||
|
}
|
||||||
|
|
||||||
|
const callId = call.id || call.call_id;
|
||||||
|
const normOther = normalizePhone(otherPartyNumber);
|
||||||
|
|
||||||
|
// Map call result → status
|
||||||
|
const result = (call.result || '').toLowerCase();
|
||||||
|
let callStatus = 'completed';
|
||||||
|
if (result.includes('missed') || result.includes('no answer')) callStatus = 'missed';
|
||||||
|
else if (result.includes('voicemail')) callStatus = 'voicemail';
|
||||||
|
else if (result.includes('busy')) callStatus = 'busy';
|
||||||
|
else if (result.includes('failed') || result.includes('cancel')) callStatus = 'failed';
|
||||||
|
|
||||||
|
// Cross-reference: contact → company
|
||||||
|
let matchedContactId: number | null = null;
|
||||||
|
let matchedCompanyId: number | null = null;
|
||||||
|
|
||||||
|
if (direction !== 'internal' && normOther) {
|
||||||
|
const contactMatch = phoneIndex.get(normOther);
|
||||||
|
if (contactMatch) {
|
||||||
|
matchedContactId = contactMatch.contactId;
|
||||||
|
matchedCompanyId = contactMatch.companyId;
|
||||||
|
} else {
|
||||||
|
const companyId = companyPhoneIndex.get(normOther);
|
||||||
|
if (companyId) matchedCompanyId = companyId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedContactId || matchedCompanyId) callsMatched++;
|
||||||
|
|
||||||
|
await postgresClient.query(
|
||||||
|
`INSERT INTO zoom_calls (
|
||||||
|
zoom_call_id, resource_email, direction, call_status,
|
||||||
|
other_party_number, other_party_name,
|
||||||
|
start_time, duration_seconds,
|
||||||
|
matched_contact_id, matched_company_id, synced_at
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW())
|
||||||
|
ON CONFLICT (zoom_call_id) DO UPDATE SET
|
||||||
|
start_time = EXCLUDED.start_time,
|
||||||
|
duration_seconds = EXCLUDED.duration_seconds,
|
||||||
|
call_status = EXCLUDED.call_status,
|
||||||
|
matched_contact_id = EXCLUDED.matched_contact_id,
|
||||||
|
matched_company_id = EXCLUDED.matched_company_id,
|
||||||
|
synced_at = NOW()`,
|
||||||
|
[
|
||||||
|
callId,
|
||||||
|
user.email.toLowerCase(),
|
||||||
|
direction,
|
||||||
|
callStatus,
|
||||||
|
otherPartyNumber || null,
|
||||||
|
otherPartyName || null,
|
||||||
|
call.date_time ? new Date(call.date_time) : null,
|
||||||
|
call.duration ?? null,
|
||||||
|
matchedContactId,
|
||||||
|
matchedCompanyId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
callsUpserted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[ZOOM-SYNC] Calls: ${callsUpserted} upserted, ${callsMatched} matched to contacts/companies`);
|
||||||
|
|
||||||
|
// ─── Step 4: Sync past meetings (90-day window) ───────────────────
|
||||||
|
const meetingFrom = toIsoDate(new Date(Date.now() - MEETING_WINDOW_DAYS * 24 * 60 * 60 * 1000));
|
||||||
|
const meetingTo = toIsoDate(new Date());
|
||||||
|
|
||||||
|
// Build map of zoom_id → email for host resolution
|
||||||
|
const zoomIdToEmail = new Map<string, string>(
|
||||||
|
activeZoomUsers.map(u => [u.id, u.email.toLowerCase()])
|
||||||
|
);
|
||||||
|
|
||||||
|
let meetingsUpserted = 0;
|
||||||
|
|
||||||
|
// meeting DB id map: zoom_meeting_id → db id (for participant insert)
|
||||||
|
const meetingDbIds = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const user of activeZoomUsers) {
|
||||||
|
console.log(`[ZOOM-SYNC] Fetching past meetings for ${user.email}...`);
|
||||||
|
const meetings = await client.getUserPastMeetings(user.id, meetingFrom, meetingTo);
|
||||||
|
|
||||||
|
for (const meeting of meetings) {
|
||||||
|
const meetingId = String(meeting.id);
|
||||||
|
const hostEmail = meeting.host_email ?? zoomIdToEmail.get(meeting.host_id) ?? user.email.toLowerCase();
|
||||||
|
const endTime = meeting.end_time
|
||||||
|
? new Date(meeting.end_time)
|
||||||
|
: meeting.start_time
|
||||||
|
? new Date(new Date(meeting.start_time).getTime() + (meeting.duration ?? 0) * 60000)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const result = await postgresClient.query(
|
||||||
|
`INSERT INTO zoom_meetings (
|
||||||
|
zoom_meeting_id, zoom_meeting_uuid, host_email,
|
||||||
|
topic, start_time, end_time, duration_minutes,
|
||||||
|
participant_count, client_participant_count, has_client_attendees,
|
||||||
|
synced_at
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,0,false,NOW())
|
||||||
|
ON CONFLICT (zoom_meeting_id) DO UPDATE SET
|
||||||
|
zoom_meeting_uuid = EXCLUDED.zoom_meeting_uuid,
|
||||||
|
host_email = EXCLUDED.host_email,
|
||||||
|
topic = EXCLUDED.topic,
|
||||||
|
start_time = EXCLUDED.start_time,
|
||||||
|
end_time = EXCLUDED.end_time,
|
||||||
|
duration_minutes = EXCLUDED.duration_minutes,
|
||||||
|
participant_count = EXCLUDED.participant_count,
|
||||||
|
synced_at = NOW()
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
meetingId,
|
||||||
|
meeting.uuid || null,
|
||||||
|
hostEmail,
|
||||||
|
meeting.topic || null,
|
||||||
|
meeting.start_time ? new Date(meeting.start_time) : null,
|
||||||
|
endTime,
|
||||||
|
meeting.duration ?? null,
|
||||||
|
meeting.participants_count ?? 0,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows[0]) {
|
||||||
|
meetingDbIds.set(meetingId, result.rows[0].id);
|
||||||
|
meetingsUpserted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[ZOOM-SYNC] Meetings upserted: ${meetingsUpserted}`);
|
||||||
|
|
||||||
|
// ─── Step 5: Sync meeting participants (30-day window) ────────────
|
||||||
|
const participantCutoff = new Date(Date.now() - PARTICIPANT_WINDOW_DAYS * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// Build contact email index
|
||||||
|
const contactEmailResult = await postgresClient.query(
|
||||||
|
`SELECT id, company_id, LOWER(email_address) as email1,
|
||||||
|
LOWER(email_address2) as email2, LOWER(email_address3) as email3
|
||||||
|
FROM contacts
|
||||||
|
WHERE (is_deleted = false OR is_deleted IS NULL)`
|
||||||
|
);
|
||||||
|
const contactEmailIndex = new Map<string, { contactId: number; companyId: number | null }>();
|
||||||
|
for (const row of contactEmailResult.rows) {
|
||||||
|
for (const e of [row.email1, row.email2, row.email3]) {
|
||||||
|
if (e && !contactEmailIndex.has(e)) {
|
||||||
|
contactEmailIndex.set(e, { contactId: row.id, companyId: row.company_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let participantsUpserted = 0;
|
||||||
|
|
||||||
|
// Process meetings within the 30-day participant window
|
||||||
|
const recentMeetingIds = await postgresClient.query(
|
||||||
|
`SELECT id, zoom_meeting_id FROM zoom_meetings
|
||||||
|
WHERE start_time >= $1`,
|
||||||
|
[participantCutoff]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const meetingRow of recentMeetingIds.rows) {
|
||||||
|
const dbMeetingId: number = meetingRow.id;
|
||||||
|
const zoomMeetingId: string = meetingRow.zoom_meeting_id;
|
||||||
|
|
||||||
|
// Clear existing participants for this meeting (re-sync)
|
||||||
|
await postgresClient.query(
|
||||||
|
`DELETE FROM zoom_meeting_participants WHERE meeting_id = $1`,
|
||||||
|
[dbMeetingId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const participants = await client.getMeetingParticipants(zoomMeetingId);
|
||||||
|
|
||||||
|
let clientParticipantCount = 0;
|
||||||
|
let hasClientAttendees = false;
|
||||||
|
|
||||||
|
for (const p of participants) {
|
||||||
|
const email = (p.user_email || '').toLowerCase();
|
||||||
|
const isInternal = email ? resourceEmails.has(email) : false;
|
||||||
|
|
||||||
|
let matchedContactId: number | null = null;
|
||||||
|
let matchedCompanyId: number | null = null;
|
||||||
|
|
||||||
|
if (email && !isInternal) {
|
||||||
|
const match = contactEmailIndex.get(email);
|
||||||
|
if (match) {
|
||||||
|
matchedContactId = match.contactId;
|
||||||
|
matchedCompanyId = match.companyId;
|
||||||
|
clientParticipantCount++;
|
||||||
|
hasClientAttendees = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await postgresClient.query(
|
||||||
|
`INSERT INTO zoom_meeting_participants (
|
||||||
|
meeting_id, participant_email, participant_name,
|
||||||
|
duration_seconds, matched_contact_id, matched_company_id,
|
||||||
|
is_internal, created_at
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,NOW())`,
|
||||||
|
[
|
||||||
|
dbMeetingId,
|
||||||
|
email || null,
|
||||||
|
p.name || null,
|
||||||
|
p.duration ?? null,
|
||||||
|
matchedContactId,
|
||||||
|
matchedCompanyId,
|
||||||
|
isInternal,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
participantsUpserted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update meeting with participant stats
|
||||||
|
await postgresClient.query(
|
||||||
|
`UPDATE zoom_meetings
|
||||||
|
SET client_participant_count = $2, has_client_attendees = $3
|
||||||
|
WHERE id = $1`,
|
||||||
|
[dbMeetingId, clientParticipantCount, hasClientAttendees]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`[ZOOM-SYNC] Participants upserted: ${participantsUpserted}`);
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
console.log(`[ZOOM-SYNC] Done in ${duration}ms`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
usersUpserted,
|
||||||
|
callsUpserted,
|
||||||
|
callsMatched,
|
||||||
|
meetingsUpserted,
|
||||||
|
participantsUpserted,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
this.syncInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _instance: ZoomSyncService | null = null;
|
||||||
|
|
||||||
|
export function getZoomSyncService(): ZoomSyncService {
|
||||||
|
if (!_instance) {
|
||||||
|
_instance = new ZoomSyncService();
|
||||||
|
}
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
|
@ -281,46 +281,33 @@ export interface AutotaskTimeEntry {
|
||||||
resourceID: number;
|
resourceID: number;
|
||||||
ticketID?: number;
|
ticketID?: number;
|
||||||
taskID?: number;
|
taskID?: number;
|
||||||
projectID?: number;
|
contractID?: 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;
|
|
||||||
contractServiceID?: number;
|
contractServiceID?: number;
|
||||||
contractServiceBundleID?: 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;
|
roleID?: number;
|
||||||
departmentID?: number;
|
createDateTime?: string;
|
||||||
locationID?: number;
|
creatorUserID?: number;
|
||||||
allocationCodeID?: number;
|
lastModifiedDateTime?: string;
|
||||||
impProjectScheduleID?: number;
|
lastModifiedUserID?: number;
|
||||||
impProjectScheduleTaskID?: number;
|
impersonatorCreatorResourceID?: number;
|
||||||
apiVendorID?: number;
|
impersonatorUpdaterResourceID?: number;
|
||||||
createDate: string; // ISO datetime string
|
|
||||||
lastModifiedDate?: string; // ISO datetime string
|
|
||||||
userDefinedFields?: Array<{
|
|
||||||
name: string;
|
|
||||||
value: any;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiResponse<T> {
|
export interface ApiResponse<T> {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ export enum EntityType {
|
||||||
CONFIGURATION_ITEMS = 'configuration_items',
|
CONFIGURATION_ITEMS = 'configuration_items',
|
||||||
CONTACTS = 'contacts',
|
CONTACTS = 'contacts',
|
||||||
CONTRACTS = 'contracts',
|
CONTRACTS = 'contracts',
|
||||||
|
CONTRACT_SERVICES = 'contract_services',
|
||||||
|
AUTOTASK_SERVICES = 'autotask_services',
|
||||||
TIME_ENTRIES = 'time_entries',
|
TIME_ENTRIES = 'time_entries',
|
||||||
TICKET_NOTES = 'ticket_notes',
|
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.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.CONFIGURATION_ITEMS]: [EntityType.COMPANIES, EntityType.CONTACTS], // Depends on companies and contacts
|
||||||
[EntityType.CONTRACTS]: [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.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.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
|
[EntityType.TICKET_NOTES]: [EntityType.TICKETS], // Depends on tickets
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,13 @@ export interface ZabbixHost {
|
||||||
host: string;
|
host: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
description?: string;
|
||||||
interfaces?: ZabbixHostInterface[];
|
interfaces?: ZabbixHostInterface[];
|
||||||
groups?: ZabbixHostGroup[];
|
groups?: ZabbixHostGroup[];
|
||||||
templates?: ZabbixTemplate[];
|
templates?: ZabbixTemplate[];
|
||||||
description?: string;
|
parentTemplates?: ZabbixTemplate[];
|
||||||
|
macros?: ZabbixHostMacro[];
|
||||||
|
tags?: ZabbixHostTag[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ZabbixHostInterface {
|
export interface ZabbixHostInterface {
|
||||||
|
|
@ -69,6 +72,30 @@ export interface ZabbixHostUpdateParams {
|
||||||
tags?: ZabbixHostTag[];
|
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> {
|
export interface ZabbixRpcResponse<T> {
|
||||||
jsonrpc: string;
|
jsonrpc: string;
|
||||||
result?: T;
|
result?: T;
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,12 @@ export function mapAutotaskToDatabase(
|
||||||
case EntityType.CONTRACTS:
|
case EntityType.CONTRACTS:
|
||||||
mapped = mapContract(data);
|
mapped = mapContract(data);
|
||||||
break;
|
break;
|
||||||
|
case EntityType.CONTRACT_SERVICES:
|
||||||
|
mapped = mapContractService(data);
|
||||||
|
break;
|
||||||
|
case EntityType.AUTOTASK_SERVICES:
|
||||||
|
mapped = mapAutotaskService(data);
|
||||||
|
break;
|
||||||
case EntityType.BILLING_ITEMS:
|
case EntityType.BILLING_ITEMS:
|
||||||
mapped = mapBillingItem(data);
|
mapped = mapBillingItem(data);
|
||||||
break;
|
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
|
* Map Billing Item entity
|
||||||
*/
|
*/
|
||||||
|
|
@ -558,45 +599,32 @@ function mapBillingItem(data: any): Record<string, any> {
|
||||||
* Map Time Entry entity
|
* Map Time Entry entity
|
||||||
*/
|
*/
|
||||||
function mapTimeEntry(data: any): Record<string, any> {
|
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 {
|
return {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
resource_id: data.resourceID,
|
resource_id: data.resourceID,
|
||||||
ticket_id: data.ticketID,
|
ticket_id: data.ticketID,
|
||||||
task_id: data.taskID,
|
task_id: data.taskID,
|
||||||
project_id: data.projectID,
|
contract_id: data.contractID,
|
||||||
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_service_id: data.contractServiceID,
|
contract_service_id: data.contractServiceID,
|
||||||
contract_service_bundle_id: data.contractServiceBundleID,
|
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,
|
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(),
|
synced_at: new Date(),
|
||||||
is_deleted: false,
|
is_deleted: false,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -65,11 +65,27 @@ export function getAllEntitiesInOrder(): EntityType[] {
|
||||||
EntityType.TASKS,
|
EntityType.TASKS,
|
||||||
EntityType.CONFIGURATION_ITEMS,
|
EntityType.CONFIGURATION_ITEMS,
|
||||||
EntityType.CONTRACTS,
|
EntityType.CONTRACTS,
|
||||||
|
EntityType.CONTRACT_SERVICES,
|
||||||
|
EntityType.AUTOTASK_SERVICES,
|
||||||
EntityType.BILLING_ITEMS,
|
EntityType.BILLING_ITEMS,
|
||||||
EntityType.TIME_ENTRIES,
|
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
|
* Get table name for entity type
|
||||||
* @param entity Entity type
|
* @param entity Entity type
|
||||||
|
|
@ -102,6 +118,8 @@ export function getAutotaskEntityName(entity: EntityType): string {
|
||||||
[EntityType.CONFIGURATION_ITEMS]: 'ConfigurationItems',
|
[EntityType.CONFIGURATION_ITEMS]: 'ConfigurationItems',
|
||||||
[EntityType.CONTACTS]: 'Contacts',
|
[EntityType.CONTACTS]: 'Contacts',
|
||||||
[EntityType.CONTRACTS]: 'Contracts',
|
[EntityType.CONTRACTS]: 'Contracts',
|
||||||
|
[EntityType.CONTRACT_SERVICES]: 'ContractServices',
|
||||||
|
[EntityType.AUTOTASK_SERVICES]: 'Services',
|
||||||
[EntityType.TIME_ENTRIES]: 'TimeEntries',
|
[EntityType.TIME_ENTRIES]: 'TimeEntries',
|
||||||
[EntityType.TICKET_NOTES]: 'TicketNotes',
|
[EntityType.TICKET_NOTES]: 'TicketNotes',
|
||||||
};
|
};
|
||||||
|
|
@ -141,6 +159,8 @@ export function getLastModifiedField(entity: EntityType): string {
|
||||||
[EntityType.CONFIGURATION_ITEMS]: 'lastModifiedTime',
|
[EntityType.CONFIGURATION_ITEMS]: 'lastModifiedTime',
|
||||||
[EntityType.CONTACTS]: 'lastModifiedDate',
|
[EntityType.CONTACTS]: 'lastModifiedDate',
|
||||||
[EntityType.CONTRACTS]: 'lastModifiedDateTime',
|
[EntityType.CONTRACTS]: 'lastModifiedDateTime',
|
||||||
|
[EntityType.CONTRACT_SERVICES]: 'lastModifiedDate',
|
||||||
|
[EntityType.AUTOTASK_SERVICES]: 'lastModifiedDate',
|
||||||
[EntityType.BILLING_ITEMS]: 'itemDate',
|
[EntityType.BILLING_ITEMS]: 'itemDate',
|
||||||
[EntityType.TIME_ENTRIES]: 'dateWorked',
|
[EntityType.TIME_ENTRIES]: 'dateWorked',
|
||||||
[EntityType.TICKET_NOTES]: 'lastActivityDate',
|
[EntityType.TICKET_NOTES]: 'lastActivityDate',
|
||||||
|
|
@ -171,6 +191,8 @@ export function getActiveField(entity: EntityType): string | null {
|
||||||
[EntityType.CONFIGURATION_ITEMS]: 'isActive',
|
[EntityType.CONFIGURATION_ITEMS]: 'isActive',
|
||||||
[EntityType.CONTACTS]: 'isActive',
|
[EntityType.CONTACTS]: 'isActive',
|
||||||
[EntityType.CONTRACTS]: null, // Use status field instead
|
[EntityType.CONTRACTS]: null, // Use status field instead
|
||||||
|
[EntityType.CONTRACT_SERVICES]: null,
|
||||||
|
[EntityType.AUTOTASK_SERVICES]: 'isActive',
|
||||||
[EntityType.BILLING_ITEMS]: null,
|
[EntityType.BILLING_ITEMS]: null,
|
||||||
[EntityType.TIME_ENTRIES]: null, // Time entries don't have active status
|
[EntityType.TIME_ENTRIES]: null, // Time entries don't have active status
|
||||||
[EntityType.TICKET_NOTES]: null, // Ticket notes 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.TIME_ENTRIES]: 'createDate', // TimeEntry uses createDate for filtering
|
||||||
[EntityType.PROJECTS]: 'startDateTime',
|
[EntityType.PROJECTS]: 'startDateTime',
|
||||||
[EntityType.BILLING_ITEMS]: 'itemDate',
|
[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.COMPANIES]: null,
|
||||||
[EntityType.RESOURCES]: null,
|
[EntityType.RESOURCES]: null,
|
||||||
[EntityType.CONTACTS]: null,
|
[EntityType.CONTACTS]: null,
|
||||||
|
|
@ -456,6 +480,8 @@ export function getEntityDisplayName(entity: EntityType): string {
|
||||||
[EntityType.CONFIGURATION_ITEMS]: 'Configuration Items',
|
[EntityType.CONFIGURATION_ITEMS]: 'Configuration Items',
|
||||||
[EntityType.CONTACTS]: 'Contacts',
|
[EntityType.CONTACTS]: 'Contacts',
|
||||||
[EntityType.CONTRACTS]: 'Contracts',
|
[EntityType.CONTRACTS]: 'Contracts',
|
||||||
|
[EntityType.CONTRACT_SERVICES]: 'Contract Services',
|
||||||
|
[EntityType.AUTOTASK_SERVICES]: 'Autotask Services',
|
||||||
[EntityType.TIME_ENTRIES]: 'Time Entries',
|
[EntityType.TIME_ENTRIES]: 'Time Entries',
|
||||||
[EntityType.TICKET_NOTES]: 'Ticket Notes',
|
[EntityType.TICKET_NOTES]: 'Ticket Notes',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
36
migrations/040_create_contract_services_table.sql
Normal file
36
migrations/040_create_contract_services_table.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS contract_services (
|
||||||
|
id BIGINT PRIMARY KEY,
|
||||||
|
contract_id BIGINT NOT NULL,
|
||||||
|
company_id BIGINT,
|
||||||
|
service_id BIGINT,
|
||||||
|
service_name TEXT,
|
||||||
|
description TEXT,
|
||||||
|
period_type INTEGER,
|
||||||
|
unit_price NUMERIC(15,2),
|
||||||
|
unit_cost NUMERIC(15,2),
|
||||||
|
discount_percent NUMERIC(5,2),
|
||||||
|
adjusted_price NUMERIC(15,2),
|
||||||
|
quantity NUMERIC(10,2),
|
||||||
|
invoice_description TEXT,
|
||||||
|
start_date DATE,
|
||||||
|
end_date DATE,
|
||||||
|
internal_currency_price NUMERIC(15,2),
|
||||||
|
create_date TIMESTAMPTZ,
|
||||||
|
last_modified_date TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contract_services_contract_id ON contract_services(contract_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contract_services_company_id ON contract_services(company_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contract_services_service_name ON contract_services(service_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contract_services_is_deleted ON contract_services(is_deleted);
|
||||||
|
|
||||||
|
ALTER TABLE contract_services
|
||||||
|
DROP CONSTRAINT IF EXISTS fk_contract_services_contract;
|
||||||
|
ALTER TABLE contract_services
|
||||||
|
ADD CONSTRAINT fk_contract_services_contract
|
||||||
|
FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE;
|
||||||
37
migrations/041_create_engagement_tables.sql
Normal file
37
migrations/041_create_engagement_tables.sql
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
-- Migration 041: Create engagement tables for Microsoft Graph activity data
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS graph_users (
|
||||||
|
id VARCHAR(255) PRIMARY KEY, -- Azure AD object ID
|
||||||
|
display_name VARCHAR(255),
|
||||||
|
email VARCHAR(255) UNIQUE, -- matches resources.email
|
||||||
|
job_title VARCHAR(255),
|
||||||
|
department VARCHAR(255),
|
||||||
|
account_enabled BOOLEAN DEFAULT true,
|
||||||
|
last_activity_date DATE,
|
||||||
|
synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS engagement_snapshots (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_email VARCHAR(255) NOT NULL,
|
||||||
|
period_type VARCHAR(10) NOT NULL, -- 'D7', 'D30', 'D90'
|
||||||
|
period_end DATE NOT NULL,
|
||||||
|
-- Teams
|
||||||
|
teams_chat_messages INT DEFAULT 0,
|
||||||
|
teams_private_messages INT DEFAULT 0,
|
||||||
|
teams_calls INT DEFAULT 0,
|
||||||
|
teams_meetings_attended INT DEFAULT 0,
|
||||||
|
teams_meetings_organized INT DEFAULT 0,
|
||||||
|
-- Email
|
||||||
|
emails_sent INT DEFAULT 0,
|
||||||
|
emails_received INT DEFAULT 0,
|
||||||
|
emails_read INT DEFAULT 0,
|
||||||
|
-- Last activity
|
||||||
|
last_activity_date DATE,
|
||||||
|
synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_email, period_type, period_end)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_engagement_snapshots_email ON engagement_snapshots(user_email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_engagement_snapshots_period ON engagement_snapshots(period_type, period_end);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_graph_users_email ON graph_users(email);
|
||||||
6
migrations/042_add_engagement_calendar_columns.sql
Normal file
6
migrations/042_add_engagement_calendar_columns.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- Migration 042: Add meeting time and external meeting columns to engagement_snapshots
|
||||||
|
|
||||||
|
ALTER TABLE engagement_snapshots
|
||||||
|
ADD COLUMN IF NOT EXISTS audio_duration_seconds INT DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS meeting_duration_seconds INT DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS meetings_with_external INT DEFAULT 0;
|
||||||
5
migrations/043_add_hours_to_bill_to_time_entries.sql
Normal file
5
migrations/043_add_hours_to_bill_to_time_entries.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- Add hours_to_bill column to time_entries
|
||||||
|
-- Autotask's read-only hoursToBill field: actual hours that will be billed,
|
||||||
|
-- which may differ from hours_worked due to contract caps or billing rules.
|
||||||
|
ALTER TABLE time_entries
|
||||||
|
ADD COLUMN IF NOT EXISTS hours_to_bill DECIMAL(10, 2);
|
||||||
4
migrations/044_add_contract_id_to_time_entries.sql
Normal file
4
migrations/044_add_contract_id_to_time_entries.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- Add contract_id column to time_entries
|
||||||
|
-- Autotask contractID field on time entries
|
||||||
|
ALTER TABLE time_entries
|
||||||
|
ADD COLUMN IF NOT EXISTS contract_id BIGINT;
|
||||||
58
migrations/045_create_zoom_tables.sql
Normal file
58
migrations/045_create_zoom_tables.sql
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
-- Zoom Phone and Meetings Integration Tables
|
||||||
|
-- Supports cross-referencing with Autotask contacts/companies
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS zoom_users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
zoom_id VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE,
|
||||||
|
display_name VARCHAR(255),
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
synced_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS zoom_calls (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
zoom_call_id VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
resource_email VARCHAR(255),
|
||||||
|
direction VARCHAR(20),
|
||||||
|
call_status VARCHAR(20),
|
||||||
|
other_party_number VARCHAR(50),
|
||||||
|
other_party_name VARCHAR(255),
|
||||||
|
start_time TIMESTAMPTZ,
|
||||||
|
duration_seconds INTEGER,
|
||||||
|
matched_contact_id BIGINT REFERENCES contacts(id),
|
||||||
|
matched_company_id BIGINT REFERENCES companies(id),
|
||||||
|
synced_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS zoom_meetings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
zoom_meeting_id VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
zoom_meeting_uuid VARCHAR(500),
|
||||||
|
host_email VARCHAR(255),
|
||||||
|
topic VARCHAR(500),
|
||||||
|
start_time TIMESTAMPTZ,
|
||||||
|
end_time TIMESTAMPTZ,
|
||||||
|
duration_minutes INTEGER,
|
||||||
|
participant_count INTEGER DEFAULT 0,
|
||||||
|
client_participant_count INTEGER DEFAULT 0,
|
||||||
|
has_client_attendees BOOLEAN DEFAULT false,
|
||||||
|
synced_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS zoom_meeting_participants (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
meeting_id INTEGER NOT NULL REFERENCES zoom_meetings(id) ON DELETE CASCADE,
|
||||||
|
participant_email VARCHAR(255),
|
||||||
|
participant_name VARCHAR(255),
|
||||||
|
duration_seconds INTEGER,
|
||||||
|
matched_contact_id BIGINT REFERENCES contacts(id),
|
||||||
|
matched_company_id BIGINT REFERENCES companies(id),
|
||||||
|
is_internal BOOLEAN DEFAULT false,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_zoom_calls_resource_email_time ON zoom_calls(resource_email, start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_zoom_calls_matched_company ON zoom_calls(matched_company_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_zoom_meetings_host_email_time ON zoom_meetings(host_email, start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_zoom_meeting_participants_email ON zoom_meeting_participants(participant_email);
|
||||||
28
migrations/046_create_teams_meetings_table.sql
Normal file
28
migrations/046_create_teams_meetings_table.sql
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS teams_meetings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
graph_event_id VARCHAR(500) NOT NULL,
|
||||||
|
user_email VARCHAR(255) NOT NULL,
|
||||||
|
subject VARCHAR(500),
|
||||||
|
start_time TIMESTAMPTZ,
|
||||||
|
end_time TIMESTAMPTZ,
|
||||||
|
duration_minutes INTEGER,
|
||||||
|
is_online_meeting BOOLEAN DEFAULT false,
|
||||||
|
attendee_count INTEGER DEFAULT 0,
|
||||||
|
client_attendee_count INTEGER DEFAULT 0,
|
||||||
|
has_client_attendees BOOLEAN DEFAULT false,
|
||||||
|
synced_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(user_email, graph_event_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS teams_meeting_attendees (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
meeting_id INTEGER NOT NULL REFERENCES teams_meetings(id) ON DELETE CASCADE,
|
||||||
|
attendee_email VARCHAR(255),
|
||||||
|
attendee_name VARCHAR(255),
|
||||||
|
matched_contact_id BIGINT REFERENCES contacts(id),
|
||||||
|
matched_company_id BIGINT REFERENCES companies(id),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_teams_meetings_user_email_time ON teams_meetings(user_email, start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_teams_meeting_attendees_meeting_id ON teams_meeting_attendees(meeting_id);
|
||||||
6
migrations/047_add_after_hours_messages.sql
Normal file
6
migrations/047_add_after_hours_messages.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- Migration 047: Add after-hours message tracking to engagement snapshots
|
||||||
|
-- Counts Teams messages sent by the employee between 5:30 PM and 7:00 AM UTC.
|
||||||
|
-- Populated during sync if the Azure AD app has the Chat.Read.All permission.
|
||||||
|
|
||||||
|
ALTER TABLE engagement_snapshots
|
||||||
|
ADD COLUMN IF NOT EXISTS after_hours_messages INT DEFAULT 0;
|
||||||
45
migrations/048_create_morning_summary_tables.sql
Normal file
45
migrations/048_create_morning_summary_tables.sql
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
-- Morning Summary: webhooks, config, and run history
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS morning_summary_webhooks (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
webhook_url TEXT NOT NULL,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
last_delivered_at TIMESTAMPTZ,
|
||||||
|
last_status TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS morning_summary_config (
|
||||||
|
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||||
|
weekend_suppression BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
monday_extended_window BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
severity_filter INTEGER NOT NULL DEFAULT 2,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO morning_summary_config (id, weekend_suppression, monday_extended_window, severity_filter)
|
||||||
|
VALUES (1, true, true, 2)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS morning_summaries (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
window_from TIMESTAMPTZ NOT NULL,
|
||||||
|
window_to TIMESTAMPTZ NOT NULL,
|
||||||
|
open_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
resolved_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
mttr_minutes INTEGER,
|
||||||
|
clients_affected TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
is_weekend_window BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
card_payload JSONB,
|
||||||
|
delivery_status JSONB NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_morning_summaries_generated_at ON morning_summaries(generated_at DESC);
|
||||||
|
|
||||||
|
INSERT INTO morning_summary_webhooks (label, webhook_url, enabled) VALUES
|
||||||
|
('Technical / On-Call', 'https://appriver3651004158.webhook.office.com/webhookb2/43394fff-d546-440f-ae34-eed04c5bcd5d@1c260a52-b84d-4b7a-a179-44971dbc3949/IncomingWebhook/fe9e1350791f4923bdbd56c076eae6ed/ee54b2ef-4f27-4188-b865-47fda0eae4d1/V2y29hRVISyBYOdDq6dPs-ekvlhqCF6XGE3yUUFxt1dKQ1', true),
|
||||||
|
('Technical / Outages-Issues','https://appriver3651004158.webhook.office.com/webhookb2/43394fff-d546-440f-ae34-eed04c5bcd5d@1c260a52-b84d-4b7a-a179-44971dbc3949/IncomingWebhook/b7cf42691520467986db5a993dcd07b6/ee54b2ef-4f27-4188-b865-47fda0eae4d1/V2kdADKBhv3h3CfOfeIbe3O6HI5hIh4BRVX5UMqIxhLE81', false),
|
||||||
|
('Finance (Testing)', 'https://appriver3651004158.webhook.office.com/webhookb2/033405b3-b02e-4ce5-a200-4a3b9f387c92@1c260a52-b84d-4b7a-a179-44971dbc3949/IncomingWebhook/5fe8882c1c0342a7bbe91d763abbca57/ee54b2ef-4f27-4188-b865-47fda0eae4d1/V2rSVLy80XTdPJlBJVzUBYrMyrTi_Tmts46dA8BZjlPaw1', false)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
380
package-lock.json
generated
380
package-lock.json
generated
|
|
@ -44,6 +44,7 @@
|
||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-hook-form": "^7.70.0",
|
"react-hook-form": "^7.70.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"redis": "^5.10.0",
|
"redis": "^5.10.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
|
@ -3943,6 +3944,42 @@
|
||||||
"@redis/client": "^5.10.0"
|
"@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": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
|
|
@ -4906,6 +4943,69 @@
|
||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
|
|
@ -4984,6 +5084,12 @@
|
||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.46.2",
|
"version": "8.46.2",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz",
|
||||||
|
|
@ -6435,6 +6541,127 @@
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"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": {
|
"node_modules/decompress-response": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
|
|
@ -7052,6 +7285,16 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
|
@ -7498,6 +7741,12 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/expand-template": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||||
|
|
@ -8073,6 +8322,16 @@
|
||||||
"node": ">= 4"
|
"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": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
|
|
@ -8127,6 +8386,15 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/ioredis": {
|
||||||
"version": "5.9.0",
|
"version": "5.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.0.tgz",
|
||||||
|
|
@ -10171,9 +10439,31 @@
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.1",
|
"version": "2.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
|
||||||
|
|
@ -10270,6 +10560,36 @@
|
||||||
"url": "https://paulmillr.com/funding/"
|
"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": {
|
"node_modules/redis": {
|
||||||
"version": "5.10.0",
|
"version": "5.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/redis/-/redis-5.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/redis/-/redis-5.10.0.tgz",
|
||||||
|
|
@ -10307,6 +10627,21 @@
|
||||||
"node": ">=4"
|
"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": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
|
|
@ -10357,6 +10692,12 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
|
|
@ -11123,6 +11464,12 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/tinyexec": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
"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": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@
|
||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-hook-form": "^7.70.0",
|
"react-hook-form": "^7.70.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"redis": "^5.10.0",
|
"redis": "^5.10.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
|
|
||||||
339
tasks/prd-morning-summary-teams.md
Normal file
339
tasks/prd-morning-summary-teams.md
Normal file
|
|
@ -0,0 +1,339 @@
|
||||||
|
# PRD: Morning NOC Summary — Teams Adaptive Card
|
||||||
|
|
||||||
|
**Version:** 1.1
|
||||||
|
**Last updated:** 2026-03-10
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Introduction / Overview
|
||||||
|
|
||||||
|
Wulf Consulting management needs a daily morning briefing on overnight infrastructure activity
|
||||||
|
across all ~46 client sites monitored by Zabbix. This feature automates that into a rich Teams
|
||||||
|
direct message delivered before the workday starts, with configurable recipients and optional
|
||||||
|
channel escalation actions directly from the card.
|
||||||
|
|
||||||
|
**Goal:** Post a Zabbix-sourced morning summary as a Teams Adaptive Card to one or more
|
||||||
|
configured Teams channel webhooks. The primary targets are the **On-Call** and/or
|
||||||
|
**Outages-Issues** channels in the **Technical** team. The card runs at 6:30 AM ET on
|
||||||
|
weekdays (with a Monday extended window option) and can be triggered on-demand from the
|
||||||
|
Pulse admin UI. No bot registration required — delivery is via incoming webhook URLs stored
|
||||||
|
in Pulse config.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Goals
|
||||||
|
|
||||||
|
1. Post a formatted Teams Adaptive Card to configured channel webhook(s) at 6:30 AM Mon–Fri.
|
||||||
|
2. Surface currently-open Zabbix problems (client name, trigger name, duration, severity).
|
||||||
|
3. Surface problems that resolved overnight (during the report window).
|
||||||
|
4. Show headline stats: open count, resolved count, avg MTTR, clients affected.
|
||||||
|
5. Highlight open problems active > 4 hours without acknowledgement ("Needs Attention" flag).
|
||||||
|
6. Support posting to multiple webhooks (e.g. both On-Call and Outages-Issues simultaneously).
|
||||||
|
7. Provide a **manual trigger** in the Pulse admin UI with per-webhook test send capability.
|
||||||
|
8. Support configurable weekend suppression; when Monday is the run day, extend the report
|
||||||
|
window to cover the full weekend (Friday 6 PM → Monday 6:30 AM).
|
||||||
|
9. Persist each generated summary to the Pulse database and show it as a dashboard widget.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. User Stories
|
||||||
|
|
||||||
|
- **As Tom Carlin / Lorentz Hinrichsen**, I want to see the morning NOC summary posted to
|
||||||
|
the Teams channels I already monitor so I get the briefing without checking a separate tool.
|
||||||
|
- **As a Pulse admin**, I want to configure which channel webhooks receive the summary without
|
||||||
|
touching code or env files.
|
||||||
|
- **As a Pulse admin**, I want to send a test post to a specific webhook to verify formatting
|
||||||
|
before the scheduled run.
|
||||||
|
- **As a Pulse admin**, I want to toggle weekend suppression and Monday extended window.
|
||||||
|
- **As any Pulse user**, I want to see the most recent summary on the Pulse dashboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Functional Requirements
|
||||||
|
|
||||||
|
### 4.1 Data Collection (Zabbix only — v1)
|
||||||
|
|
||||||
|
1. The system **must** query `problem.get` (with `recent: true`, `selectHosts: extend`,
|
||||||
|
`selectAcknowledges: extend`, `severities: [2,3,4,5]`) to retrieve all currently open
|
||||||
|
problems at run time.
|
||||||
|
2. The system **must** query `event.get` with `value: 0` (recovery events) between
|
||||||
|
`time_from` = window start and `time_to` = run time to retrieve resolved problems.
|
||||||
|
3. The system **must** query `host.get` with `selectHostGroups: ['name']` once per run to
|
||||||
|
build a `hostid → { clientName, hostName }` map using groups prefixed `Clients/`.
|
||||||
|
4. The report window **must** be determined as follows:
|
||||||
|
- **Tuesday–Friday runs:** window start = previous calendar day at 18:00 ET.
|
||||||
|
- **Monday run (weekend mode on):** window start = the preceding Friday at 18:00 ET,
|
||||||
|
covering the full Sat + Sun + Mon pre-6:30 AM period.
|
||||||
|
- **Monday run (weekend mode off):** same as Tue–Fri (Sunday 18:00 ET → Monday 6:30 AM).
|
||||||
|
5. The system **must** calculate:
|
||||||
|
- **Open count** — number of active problems meeting severity filter.
|
||||||
|
- **Resolved count** — recovery events in the window.
|
||||||
|
- **Avg MTTR (minutes)** — mean of `(r_clock - clock)` for resolved events; `null` if none.
|
||||||
|
- **Clients affected** — deduplicated client names from open problems.
|
||||||
|
6. Any open problem where `clock` is > 4 hours before run time **and** `acknowledged` array
|
||||||
|
is empty **must** be flagged `needsAttention: true`.
|
||||||
|
|
||||||
|
### 4.2 Webhook Configuration — Pulse UI
|
||||||
|
|
||||||
|
7. The admin UI **must** provide a **Webhooks** section on `/admin/morning-summary` to manage
|
||||||
|
the list of Teams incoming webhook URLs that receive the summary.
|
||||||
|
8. The admin **must** be able to **add** a webhook by providing:
|
||||||
|
- A display label (e.g. `Technical / On-Call`)
|
||||||
|
- The incoming webhook URL
|
||||||
|
- An enabled toggle
|
||||||
|
9. The admin **must** be able to **remove** or **disable** any webhook.
|
||||||
|
10. Webhooks **must** be stored in Postgres (new table `morning_summary_webhooks`) with:
|
||||||
|
- `id` (serial PK)
|
||||||
|
- `label` (text) — human-friendly name
|
||||||
|
- `webhook_url` (text)
|
||||||
|
- `enabled` (boolean, default true)
|
||||||
|
- `last_delivered_at` (timestamptz, nullable)
|
||||||
|
- `last_status` (text, nullable) — `success` or `failed`
|
||||||
|
- `created_at` (timestamptz)
|
||||||
|
11. The admin UI **must** provide a **"Test"** button next to each webhook that posts the
|
||||||
|
current summary card to that URL only, independent of the scheduled run.
|
||||||
|
12. The test endpoint **must** be `POST /api/notifications/morning-summary/test` with
|
||||||
|
body `{ "webhookId": <id> }`.
|
||||||
|
13. The UI **must** display per-webhook last delivery status inline.
|
||||||
|
|
||||||
|
### 4.3 Teams Adaptive Card — Structure
|
||||||
|
|
||||||
|
14. The card **must** include a header `TextBlock`: `☀️ Morning NOC Summary — {date}`.
|
||||||
|
- On Monday with weekend mode: append ` (Weekend Coverage)` to the header.
|
||||||
|
15. The card **must** include a stat `ColumnSet`: Open (red), Resolved (green), Avg MTTR.
|
||||||
|
16. If there are **open problems**, the card **must** include a red-styled `Container`
|
||||||
|
labelled `🔴 OPEN ISSUES` with a `FactSet`:
|
||||||
|
- **Title:** `{ClientName} / {HostName}`
|
||||||
|
- **Value:** `{TriggerName} — {duration}` (+ ` ⚠️ Needs Attention` if flagged)
|
||||||
|
17. If there are **no open problems**, the card **must** show a green "✅ All Clear" container.
|
||||||
|
18. If there are **resolved overnight** events, the card **must** include a green-styled
|
||||||
|
`Container` labelled `🟢 RESOLVED OVERNIGHT`. If more than 5, show 5 and append
|
||||||
|
`+{N} more — all resolved`.
|
||||||
|
19. The card **must** include three action buttons:
|
||||||
|
- `Open Zabbix` → `https://zabbix.wulfconsulting.cloud`
|
||||||
|
- `Open Pulse` → `https://pulse.wulfconsulting.cloud`
|
||||||
|
- `View Problems` → `https://zabbix.wulfconsulting.cloud/zabbix.php?action=problem.view`
|
||||||
|
20. The card **must** use Adaptive Card schema version `1.4`.
|
||||||
|
21. The card payload **must** be wrapped in the Teams incoming webhook envelope:
|
||||||
|
`{ "type": "message", "attachments": [{ "contentType": "application/vnd.microsoft.card.adaptive", "content": <card> }] }`
|
||||||
|
|
||||||
|
### 4.4 Delivery via Incoming Webhooks
|
||||||
|
|
||||||
|
22. The system **must** POST the Adaptive Card envelope to each enabled webhook URL.
|
||||||
|
23. Delivery **must** be attempted for all enabled webhooks regardless of individual failures.
|
||||||
|
24. Each delivery result (HTTP status, error message) **must** be recorded in
|
||||||
|
`morning_summaries.delivery_status` keyed by webhook ID.
|
||||||
|
25. The send endpoint `POST /api/notifications/morning-summary/send` **must** accept an
|
||||||
|
optional `{ "webhookIds": [1, 2] }` body to target specific webhooks; if omitted, all
|
||||||
|
enabled webhooks are used.
|
||||||
|
26. Incoming webhook URLs are obtained from Teams: channel → connectors → "Incoming Webhook".
|
||||||
|
The admin pastes the URL into the Pulse webhook config UI.
|
||||||
|
|
||||||
|
### 4.5 MS Graph Usage (read-only)
|
||||||
|
|
||||||
|
27. MS Graph is **not** used for delivery in v1 (webhooks handle that).
|
||||||
|
28. MS Graph `getUsers()` **may** still be used in the admin UI to resolve display names when
|
||||||
|
configuring webhook labels, but is not required for the core send flow.
|
||||||
|
29. No `ChatMessage.Send` or `ChannelMessage.Send` Graph permissions are needed for v1.
|
||||||
|
|
||||||
|
### 4.6 Scheduling & Weekend Mode
|
||||||
|
|
||||||
|
33. The job **must** be registered in `SyncScheduler` with cron `30 6 * * 1-5`.
|
||||||
|
34. A `morning_summary_config` table (single-row settings) **must** store:
|
||||||
|
- `weekend_suppression` (boolean) — if true, skip Sat/Sun runs.
|
||||||
|
- `monday_extended_window` (boolean) — if true and today is Monday, set window start
|
||||||
|
to preceding Friday 18:00 ET.
|
||||||
|
- `severity_filter` (int, default 2) — minimum Zabbix severity to include.
|
||||||
|
- `updated_at` (timestamptz)
|
||||||
|
35. The admin UI **must** expose toggles for `weekend_suppression` and `monday_extended_window`
|
||||||
|
with a clear label: _"On Mondays, extend window to cover the full weekend (Fri 6 PM → Mon
|
||||||
|
6:30 AM)"_.
|
||||||
|
36. The admin UI **must** display the next scheduled run time calculated from the cron
|
||||||
|
expression and current settings.
|
||||||
|
|
||||||
|
### 4.7 Manual Trigger — Pulse UI
|
||||||
|
|
||||||
|
37. The admin UI at `/admin/morning-summary` **must** provide:
|
||||||
|
- **Schedule status:** next run time, last run time, last run result.
|
||||||
|
- **"Send Now"** button — fires `POST /api/notifications/morning-summary/send` to all
|
||||||
|
enabled webhooks immediately.
|
||||||
|
- **Per-webhook "Test"** buttons (see §4.2 req 11).
|
||||||
|
- **Weekend mode toggles** (see §4.6 req 35).
|
||||||
|
- **Webhooks section** — add/remove/enable-disable webhook entries (see §4.2).
|
||||||
|
- **Last card preview** — most recent `card_payload` as formatted JSON (collapsed by default).
|
||||||
|
38. The UI **must** show a toast on send and display per-webhook delivery results inline.
|
||||||
|
|
||||||
|
### 4.8 Dashboard Widget & Persistence
|
||||||
|
|
||||||
|
39. Each run (scheduled or manual/test) **must** persist to `morning_summaries`:
|
||||||
|
- `id` (serial PK)
|
||||||
|
- `generated_at` (timestamptz)
|
||||||
|
- `window_from` (timestamptz)
|
||||||
|
- `window_to` (timestamptz)
|
||||||
|
- `open_count` (int)
|
||||||
|
- `resolved_count` (int)
|
||||||
|
- `mttr_minutes` (int, nullable)
|
||||||
|
- `clients_affected` (text[])
|
||||||
|
- `is_weekend_window` (boolean)
|
||||||
|
- `card_payload` (jsonb)
|
||||||
|
- `delivery_status` (jsonb) — `{ [webhookId]: { success: bool, httpStatus?: number, error?: string } }`
|
||||||
|
40. The Pulse home/dashboard **must** display a "Morning Summary" widget with: open count,
|
||||||
|
resolved count, MTTR, window label, last generated timestamp, and a link to the admin
|
||||||
|
page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Non-Goals (Out of Scope for v1)
|
||||||
|
|
||||||
|
- Veeam backup failure data (deferred to v2).
|
||||||
|
- PSA/Autotask ticket correlation and "unmatched alerts" (deferred to v2).
|
||||||
|
- Direct Teams DMs to individual users (requires bot registration — out of scope).
|
||||||
|
- Email or ntfy delivery channels (webhooks only for v1).
|
||||||
|
- ISP outage detection / grouping by ISP.
|
||||||
|
- Per-channel content customization (all webhooks receive the same card).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Design Considerations
|
||||||
|
|
||||||
|
### Adaptive Card Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ ☀️ Morning NOC Summary — Mon Mar 16 (Weekend) │
|
||||||
|
├──────────┬────────────┬────────────────────────── │
|
||||||
|
│ 3 Open │ 12 Resolved│ 22m Avg MTTR │
|
||||||
|
├──────────────────────────────────────────────────┤
|
||||||
|
│ 🔴 OPEN ISSUES │
|
||||||
|
│ Kuhn's / FW-01 Host Unreachable — 6h 12m ⚠️ │
|
||||||
|
│ ADM / SW-Core High Packet Loss — 2h 5m │
|
||||||
|
│ Seubert / DC-01 Host Unreachable — 1h 20m │
|
||||||
|
├──────────────────────────────────────────────────┤
|
||||||
|
│ 🟢 RESOLVED OVERNIGHT │
|
||||||
|
│ Brodaks / RTR-01 Slow Response — 22m │
|
||||||
|
│ +11 more — all resolved │
|
||||||
|
├──────────────────────────────────────────────────┤
|
||||||
|
│[Open Zabbix][Open Pulse][View Problems] │
|
||||||
|
│[📣 Notify On Call] [🚨 Post to Outages] │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- `"style": "attention"` container → red in Teams (open issues).
|
||||||
|
- `"style": "good"` container → green in Teams (resolved).
|
||||||
|
- Duration: `Xh Ym` for ≥ 1 hour, `Xm` for < 1 hour.
|
||||||
|
- Escalation buttons open a Pulse URL in browser; Pulse posts to channel server-side
|
||||||
|
and returns a simple confirmation page.
|
||||||
|
|
||||||
|
### Recipient Management UI (on `/admin/morning-summary`)
|
||||||
|
|
||||||
|
```
|
||||||
|
Recipients
|
||||||
|
┌────────────────────────────┬──────────┬────────────────────┐
|
||||||
|
│ Name / Email │ Status │ Actions │
|
||||||
|
├────────────────────────────┼──────────┼────────────────────┤
|
||||||
|
│ Tom Carlin │ ✅ Last: │ [Test] [Remove] │
|
||||||
|
│ tom@wulfconsulting.com │ today │ │
|
||||||
|
├────────────────────────────┼──────────┼────────────────────┤
|
||||||
|
│ Lorentz Hinrichsen │ ✅ Last: │ [Test] [Remove] │
|
||||||
|
│ lorentz@wulfconsulting.com │ today │ │
|
||||||
|
└────────────────────────────┴──────────┴────────────────────┘
|
||||||
|
[+ Add Recipient ▾] ← searchable dropdown of tenant users
|
||||||
|
```
|
||||||
|
|
||||||
|
### Webhook Delivery Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
// For each enabled webhook in morning_summary_webhooks:
|
||||||
|
POST {webhook_url}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"attachments": [{
|
||||||
|
"contentType": "application/vnd.microsoft.card.adaptive",
|
||||||
|
"contentUrl": null,
|
||||||
|
"content": { ...adaptive card JSON... }
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To get a webhook URL in Teams: go to the target channel → **...** → Connectors →
|
||||||
|
**Incoming Webhook** → Configure → copy URL → paste into Pulse admin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Technical Considerations
|
||||||
|
|
||||||
|
### Existing Infrastructure to Reuse
|
||||||
|
|
||||||
|
- **`ZabbixClient`** (`lib/services/zabbix-client.ts`) — add `getOpenProblems()` and
|
||||||
|
`getResolvedEvents(from: Date, to: Date)` methods.
|
||||||
|
- **`MsGraphClient`** — no changes needed for v1.
|
||||||
|
- **`SyncScheduler`** — add `'morning-summary'` sync type; register in `initialize()`.
|
||||||
|
- **Postgres** — three new tables: `morning_summaries`, `morning_summary_webhooks`,
|
||||||
|
`morning_summary_config`.
|
||||||
|
|
||||||
|
### New Files to Create
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `lib/services/morning-summary-service.ts` | Aggregation, card building, webhook delivery |
|
||||||
|
| `app/api/notifications/morning-summary/send/route.ts` | `POST` — send to all/selected webhooks |
|
||||||
|
| `app/api/notifications/morning-summary/test/route.ts` | `POST` — test send to single webhook |
|
||||||
|
| `app/api/notifications/morning-summary/webhooks/route.ts` | `GET`/`POST` — manage webhook list |
|
||||||
|
| `app/api/notifications/morning-summary/webhooks/[id]/route.ts` | `PUT`/`DELETE` — update/remove webhook |
|
||||||
|
| `app/api/notifications/morning-summary/config/route.ts` | `GET`/`PUT` — read/update settings |
|
||||||
|
| `app/admin/morning-summary/page.tsx` | Admin UI: webhooks, schedule, config, preview |
|
||||||
|
| `db/migrations/XXXX_morning_summary.sql` | All three new tables |
|
||||||
|
|
||||||
|
### Zabbix API Notes
|
||||||
|
|
||||||
|
- `problem.get` returns `acknowledged` as `"0"/"1"` strings and `clock` as Unix timestamp string.
|
||||||
|
- Use `selectAcknowledges: 'extend'` — problem is unacknowledged if the array is empty.
|
||||||
|
- `event.get` with `value: 0` returns recoveries; duration = `r_clock - clock` (both seconds).
|
||||||
|
- Enrich problems with host/client by cross-referencing `objectid` (triggerid) against a
|
||||||
|
trigger→host map built from `trigger.get` with `selectHosts: ['hostid', 'name']`, or
|
||||||
|
directly via `problem.get` with `selectSuppressionData` + a pre-built hostid→client map.
|
||||||
|
|
||||||
|
### MS Graph Permissions
|
||||||
|
|
||||||
|
No Graph permissions are required for v1 delivery. Delivery uses Teams incoming webhook URLs
|
||||||
|
(plain HTTPS POST — no auth needed beyond the secret embedded in the URL).
|
||||||
|
|
||||||
|
Existing granted permissions (`Chat.Create`, `Team.ReadBasic.All`, `Channel.ReadBasic.All`,
|
||||||
|
`ChatMessage.Send`, `ChannelMessage.Send`) are unused by this feature but can remain for
|
||||||
|
future use.
|
||||||
|
|
||||||
|
### Timezone
|
||||||
|
|
||||||
|
- Cron `30 6 * * 1-5` fires at 6:30 AM server time — ensure server is set to ET or adjust cron.
|
||||||
|
- Report window computed in ET using `Intl.DateTimeFormat` or `date-fns-tz`.
|
||||||
|
- Monday extended window: `startOfDay(subDays(monday, 3)) + 18h` = Friday 18:00 ET.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Success Metrics
|
||||||
|
|
||||||
|
- All configured recipients receive the DM at 6:30 AM on weekdays without manual action.
|
||||||
|
- Test send from the UI delivers to the selected individual within 30 seconds.
|
||||||
|
- Escalation button (On Call or Outages) posts to the correct Teams channel within 10 seconds
|
||||||
|
and the user sees a confirmation page.
|
||||||
|
- Monday card correctly shows the extended weekend window label and data.
|
||||||
|
- Dashboard widget reflects the most recent summary within 1 minute of generation.
|
||||||
|
- Zero unhandled errors surfaced to end users; all failures logged and shown in the admin UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Open Questions
|
||||||
|
|
||||||
|
1. ~~**MS Graph permissions**~~ — Not needed for v1 webhook delivery.
|
||||||
|
2. ~~**Team/channel names**~~ — Confirmed: team = `Technical`, channels = `On-Call` and `Outages-Issues`.
|
||||||
|
3. ~~**App membership in Technical team**~~ — Not needed; webhook URLs bypass Graph entirely.
|
||||||
|
4. **Error alerting** — If the 6:30 AM scheduled job fails entirely, how should that be
|
||||||
|
surfaced? (ntfy push, Pulse admin badge, or both?)
|
||||||
|
5. **Severity filter default** — Spec defaults to Warning (2) and above. Should
|
||||||
|
Information (1) problems be included? Confirm with Tom/Lorentz.
|
||||||
|
6. **Webhook URLs** — Lorentz needs to create incoming webhooks in the `On-Call` and
|
||||||
|
`Outages-Issues` channels (Teams channel → ... → Connectors → Incoming Webhook → Configure)
|
||||||
|
and paste the URLs into the Pulse admin UI once the feature is deployed.
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue