feat: Autotask webhook integration, TicketNotes, Datto RMM, workflow engine, Veeam agents/alarms, AI triage, misc improvements

This commit is contained in:
lorentz 2026-02-20 10:28:15 -05:00
parent 347cf4e298
commit d7c3dc7168
74 changed files with 37844 additions and 322 deletions

View file

@ -3,7 +3,7 @@
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Database, Table2, Users, Ticket, CheckSquare, FolderKanban, Wrench, Tag, ArrowLeft, Home, Clock } from 'lucide-react';
import { Database, Table2, Users, Ticket, CheckSquare, FolderKanban, Wrench, Tag, ArrowLeft, Home, Clock, MessageSquare } from 'lucide-react';
import Link from 'next/link';
const entities = [
@ -16,6 +16,7 @@ const entities = [
{ name: 'Configuration Items', icon: Wrench, path: '/admin/data-browser/configuration-items', description: 'Browse config items' },
{ name: 'Contacts', icon: Users, path: '/admin/data-browser/contacts', description: 'View contacts' },
{ name: 'Contracts', icon: Table2, path: '/admin/data-browser/contracts', description: 'Browse contracts' },
{ name: 'Ticket Notes', icon: MessageSquare, path: '/admin/data-browser/ticket-notes', description: 'Browse notes on tickets' },
{ name: 'Issue Types', icon: Tag, path: '/admin/data-browser/issue-types', description: 'Browse issue types' },
{ name: 'Sub-Issue Types', icon: Tag, path: '/admin/data-browser/sub-issue-types', description: 'Browse sub-issue types' },
];

View file

@ -0,0 +1,211 @@
'use client';
import { useState, useEffect } 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 DataTable from '@/components/admin/DataTable';
import DetailModal from '@/components/admin/DetailModal';
import { MessageSquare, ArrowLeft, RefreshCw, Filter, Calendar, User } from 'lucide-react';
import Link from 'next/link';
const NOTE_TYPE: Record<number, string> = {
1: 'Task Detail', 2: 'Time Entry Note', 3: 'Ticket Detail',
};
const PUBLISH_MAP: Record<number, { label: string; cls: string }> = {
1: { label: 'All Users', cls: 'bg-green-500/15 text-green-600 border border-green-500/30' },
2: { label: 'Internal', cls: 'bg-amber-500/15 text-amber-700 border border-amber-500/30' },
4: { label: 'Internal Only', cls: 'bg-slate-500/15 text-slate-500 border border-slate-500/30' },
};
export default function TicketNotesPage() {
const [notes, setNotes] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<any | null>(null);
const [showModal, setShowModal] = useState(false);
const [totalCount, setTotalCount] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(100);
const [search, setSearch] = useState('');
const [sortBy, setSortBy] = useState('create_date_time');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const fetchNotes = async (page = 1, overrideSortBy = sortBy, overrideSortOrder = sortOrder) => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (search) params.append('search', search);
params.append('limit', pageSize.toString());
params.append('offset', ((page - 1) * pageSize).toString());
params.append('sort_by', overrideSortBy);
params.append('sort_order', overrideSortOrder);
const res = await fetch(`/api/data/ticket-notes?${params}`);
if (!res.ok) throw new Error(res.statusText);
const data = await res.json();
setNotes(data.ticketNotes || []);
setTotalCount(data.pagination?.total || 0);
setCurrentPage(page);
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error');
} finally {
setLoading(false);
}
};
useEffect(() => { fetchNotes(1); }, [pageSize]);
const handleSort = (col: string, dir: 'asc' | 'desc') => {
setSortBy(col);
setSortOrder(dir);
fetchNotes(1, col, dir);
};
const columns = [
{
key: 'ticket_number',
label: 'Ticket',
sortable: false,
render: (v: string) => v
? <Badge variant="outline" className="font-mono text-xs">{v}</Badge>
: <span className="text-muted-foreground"></span>,
},
{
key: 'create_date_time',
label: 'Date',
sortable: true,
render: (v: string) => v
? <span className="inline-flex items-center gap-1 text-xs"><Calendar className="w-3 h-3" />{new Date(v).toLocaleDateString()}</span>
: <span className="text-muted-foreground"></span>,
},
{
key: 'creator_name',
label: 'Creator',
sortable: false,
render: (v: string) => v
? <span className="inline-flex items-center gap-1 text-xs"><User className="w-3 h-3" />{v}</span>
: <span className="text-muted-foreground"></span>,
},
{
key: 'publish',
label: 'Visibility',
sortable: true,
render: (v: number) => {
const p = PUBLISH_MAP[v];
return p
? <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${p.cls}`}>{p.label}</span>
: <span className="text-muted-foreground text-xs">{v}</span>;
},
},
{
key: 'note_type',
label: 'Type',
sortable: true,
render: (v: number) => <span className="text-xs text-muted-foreground">{NOTE_TYPE[v] ?? `Type ${v}`}</span>,
},
{
key: 'title',
label: 'Title',
sortable: true,
render: (v: string) => <div className="max-w-xs truncate text-sm" title={v}>{v || <span className="text-muted-foreground italic">No title</span>}</div>,
},
{
key: 'description',
label: 'Preview',
sortable: false,
render: (v: string) => <div className="max-w-sm truncate text-xs text-muted-foreground" title={v}>{v || '—'}</div>,
},
];
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/admin/data-browser">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
<span className="hidden sm:inline">Back</span>
</Button>
</Link>
<MessageSquare className="w-8 h-8 text-blue-600" />
<div>
<h1 className="text-3xl font-bold">Ticket Notes</h1>
<p className="text-muted-foreground">Browse notes attached to tickets</p>
</div>
</div>
<Button variant="outline" onClick={() => fetchNotes(currentPage)} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Filter className="w-5 h-5" />Filters</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Search title / description</Label>
<Input placeholder="Search..." value={search} onChange={e => setSearch(e.target.value)}
onKeyDown={e => e.key === 'Enter' && fetchNotes(1)} />
</div>
<div className="space-y-2">
<Label>Page Size</Label>
<Select value={pageSize.toString()} onValueChange={v => setPageSize(parseInt(v))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{['50','100','250','500'].map(v => <SelectItem key={v} value={v}>{v}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2">
<Button onClick={() => fetchNotes(1)} disabled={loading}>Apply</Button>
<Button variant="outline" onClick={() => { setSearch(''); fetchNotes(1); }}>Clear</Button>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Ticket Notes ({totalCount.toLocaleString()} total)</CardTitle>
<CardDescription>Showing {notes.length} of {totalCount.toLocaleString()} Click a row to view details</CardDescription>
</CardHeader>
<CardContent>
{error && (
<div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-md p-4 mb-4">
<p className="text-red-800 dark:text-red-200">{error}</p>
</div>
)}
<DataTable
data={notes}
columns={columns}
isLoading={loading}
onRowClick={row => { setSelected(row); setShowModal(true); }}
totalCount={totalCount}
page={currentPage}
pageSize={pageSize}
onPageChange={p => fetchNotes(p)}
onSort={handleSort}
/>
</CardContent>
</Card>
<DetailModal
open={showModal}
onOpenChange={setShowModal}
title={selected?.title || `Note #${selected?.id}`}
data={selected}
/>
</div>
);
}

View file

@ -0,0 +1,103 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ArrowLeft, Activity, Apple, Loader2, ExternalLink, CheckCircle2 } from 'lucide-react';
export default function AddigyPage() {
const [status, setStatus] = useState<any>(null);
useEffect(() => {
fetch('/api/integrations/status')
.then(r => r.json())
.then(d => setStatus(d.addigy))
.catch(() => {});
}, []);
return (
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
<div className="flex items-center gap-4">
<Link href="/admin/sync">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
Integrations
</Button>
</Link>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg border bg-muted/30">
<Apple className="w-5 h-5 text-muted-foreground" />
</div>
<div>
<h1 className="text-2xl font-bold">Apple RMM Addigy</h1>
<p className="text-sm text-muted-foreground">macOS/iOS device management, policies, compliance</p>
</div>
</div>
<div className="ml-auto">
<a href="https://app.addigy.com" target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm" className="gap-2">
<ExternalLink className="w-4 h-4" />Open Portal
</Button>
</a>
</div>
</div>
<Tabs defaultValue="status" className="w-full">
<TabsList className="grid w-full max-w-xs grid-cols-2">
<TabsTrigger value="status" className="gap-2">
<Activity className="h-4 w-4" />Status
</TabsTrigger>
<TabsTrigger value="about" className="gap-2">
<Apple className="h-4 w-4" />About
</TabsTrigger>
</TabsList>
<TabsContent value="status" className="mt-6">
{!status ? (
<div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>
) : (
<div className="space-y-4">
<div className="rounded-lg border p-4 bg-muted/30 flex items-center justify-between">
<div className="flex items-center gap-3">
<CheckCircle2 className={`w-5 h-5 ${status.configured ? 'text-green-500' : 'text-muted-foreground'}`} />
<div>
<p className="text-sm font-medium">{status.configured ? 'Connected' : 'Not configured'}</p>
<p className="text-xs text-muted-foreground">{status.apiUrl}</p>
</div>
</div>
</div>
<div className="rounded-lg border p-4 bg-muted/30 text-sm text-muted-foreground">
Full sync and database integration is planned. Device and policy data is currently available on-demand via the Addigy API.
Use the org mappings page to link Addigy organizations to Autotask companies.
</div>
<div className="flex gap-2">
<Link href="/addigy-mappings">
<Button variant="outline" size="sm">Org Mappings</Button>
</Link>
</div>
</div>
)}
</TabsContent>
<TabsContent value="about" className="mt-6">
<div className="space-y-4 text-sm text-muted-foreground">
<div className="rounded-lg border p-4 space-y-2">
<p className="font-medium text-foreground">API Endpoints Available</p>
<ul className="space-y-1 list-disc list-inside">
<li><code className="text-xs bg-muted px-1 rounded">GET /api/addigy-devices</code> managed Apple devices</li>
<li><code className="text-xs bg-muted px-1 rounded">GET /api/addigy-policies</code> device policies</li>
<li><code className="text-xs bg-muted px-1 rounded">GET /api/addigy/org-mappings</code> org to company mappings</li>
</ul>
</div>
<div className="rounded-lg border p-4 space-y-2">
<p className="font-medium text-foreground">Authentication</p>
<p>Bearer token auth via <code className="text-xs bg-muted px-1 rounded">ADDIGY_API_TOKEN</code></p>
<p>Base URL: <code className="text-xs bg-muted px-1 rounded">https://api.addigy.com/api/v2</code></p>
</div>
</div>
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -0,0 +1,89 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ArrowLeft, Activity, History, Calendar, Database } from 'lucide-react';
import SyncControlPanel from '@/components/admin/SyncControlPanel';
import SyncDashboard from '@/components/admin/SyncDashboard';
import SyncHistoryTable from '@/components/admin/SyncHistoryTable';
import SyncScheduler from '@/components/admin/SyncScheduler';
import { EntityType } from '@/lib/types/sync';
export default function AutotaskSyncPage() {
const [selectedEntities, setSelectedEntities] = useState<EntityType[]>([]);
const [isSyncing, setIsSyncing] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
if (!isSyncing) return;
const interval = setInterval(async () => {
setRefreshKey(prev => prev + 1);
try {
const res = await fetch('/api/sync/status');
if (res.ok) {
const d = await res.json();
if (!d.inProgress) setIsSyncing(false);
}
} catch {}
}, 5000);
return () => clearInterval(interval);
}, [isSyncing]);
return (
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
<div className="flex items-center gap-4">
<Link href="/admin/sync">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
Integrations
</Button>
</Link>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg border border-blue-500/30 bg-blue-500/5">
<Database className="w-5 h-5 text-blue-500" />
</div>
<div>
<h1 className="text-2xl font-bold">PSA Autotask</h1>
<p className="text-sm text-muted-foreground">Tickets, companies, contacts, time entries, configuration items</p>
</div>
</div>
</div>
<SyncControlPanel
selectedEntities={selectedEntities}
onSelectedEntitiesChange={setSelectedEntities}
onSyncStart={() => setIsSyncing(true)}
onSyncComplete={() => { setIsSyncing(false); setRefreshKey(k => k + 1); }}
isSyncing={isSyncing}
/>
<Tabs defaultValue="status" className="w-full">
<TabsList className="grid w-full max-w-lg grid-cols-3">
<TabsTrigger value="status" className="gap-2">
<Activity className="h-4 w-4" />
Status
</TabsTrigger>
<TabsTrigger value="history" className="gap-2">
<History className="h-4 w-4" />
History
</TabsTrigger>
<TabsTrigger value="schedules" className="gap-2">
<Calendar className="h-4 w-4" />
Schedules
</TabsTrigger>
</TabsList>
<TabsContent value="status" className="mt-6">
<SyncDashboard refreshKey={refreshKey} />
</TabsContent>
<TabsContent value="history" className="mt-6">
<SyncHistoryTable refreshKey={refreshKey} />
</TabsContent>
<TabsContent value="schedules" className="mt-6">
<SyncScheduler />
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -0,0 +1,103 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ArrowLeft, Activity, Network, Loader2, ExternalLink, CheckCircle2 } from 'lucide-react';
export default function AuvikPage() {
const [status, setStatus] = useState<any>(null);
useEffect(() => {
fetch('/api/integrations/status')
.then(r => r.json())
.then(d => setStatus(d.auvik))
.catch(() => {});
}, []);
return (
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
<div className="flex items-center gap-4">
<Link href="/admin/sync">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
Integrations
</Button>
</Link>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg border border-purple-500/30 bg-purple-500/5">
<Network className="w-5 h-5 text-purple-500" />
</div>
<div>
<h1 className="text-2xl font-bold">NMS Auvik</h1>
<p className="text-sm text-muted-foreground">Network devices, topology, tenant mappings</p>
</div>
</div>
<div className="ml-auto">
<a href="https://auvikapi.us5.my.auvik.com" target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm" className="gap-2">
<ExternalLink className="w-4 h-4" />Open Portal
</Button>
</a>
</div>
</div>
<Tabs defaultValue="status" className="w-full">
<TabsList className="grid w-full max-w-xs grid-cols-2">
<TabsTrigger value="status" className="gap-2">
<Activity className="h-4 w-4" />Status
</TabsTrigger>
<TabsTrigger value="about" className="gap-2">
<Network className="h-4 w-4" />About
</TabsTrigger>
</TabsList>
<TabsContent value="status" className="mt-6">
{!status ? (
<div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>
) : (
<div className="space-y-4">
<div className="rounded-lg border p-4 bg-muted/30 flex items-center justify-between">
<div className="flex items-center gap-3">
<CheckCircle2 className={`w-5 h-5 ${status.configured ? 'text-green-500' : 'text-muted-foreground'}`} />
<div>
<p className="text-sm font-medium">{status.configured ? 'Connected' : 'Not configured'}</p>
<p className="text-xs text-muted-foreground">{status.apiUrl}</p>
</div>
</div>
</div>
<div className="rounded-lg border p-4 bg-muted/30 text-sm text-muted-foreground">
Full sync and database integration is planned. Data is currently available on-demand via the Auvik API.
Use the tenant mappings page to link Auvik tenants to Autotask companies.
</div>
<div className="flex gap-2">
<Link href="/auvik-mappings">
<Button variant="outline" size="sm">Tenant Mappings</Button>
</Link>
</div>
</div>
)}
</TabsContent>
<TabsContent value="about" className="mt-6">
<div className="space-y-4 text-sm text-muted-foreground">
<div className="rounded-lg border p-4 space-y-2">
<p className="font-medium text-foreground">API Endpoints Available</p>
<ul className="space-y-1 list-disc list-inside">
<li><code className="text-xs bg-muted px-1 rounded">GET /api/auvik/devices</code> network devices</li>
<li><code className="text-xs bg-muted px-1 rounded">GET /api/auvik/tenant-mappings</code> tenant to company mappings</li>
<li><code className="text-xs bg-muted px-1 rounded">GET /api/auvik/device-config</code> device configurations</li>
</ul>
</div>
<div className="rounded-lg border p-4 space-y-2">
<p className="font-medium text-foreground">Authentication</p>
<p>Basic auth API user + API key (Base64 encoded)</p>
<p>Region: US5 (<code className="text-xs bg-muted px-1 rounded">auvikapi.us5.my.auvik.com</code>)</p>
</div>
</div>
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -0,0 +1,243 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
ArrowLeft, Activity, History, Monitor, Loader2, RefreshCw,
ExternalLink, Server, Wifi, WifiOff, AlertTriangle, Bell, XCircle, CheckCircle2, Clock,
} from 'lucide-react';
function fmtDate(d: string | null) {
if (!d) return 'Never';
return new Date(d).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function StatCard({ label, value, sub, icon: Icon, cls }: { label: string; value: string | number; sub?: string; icon?: React.ElementType; cls?: string }) {
return (
<div className={`rounded-lg border p-4 flex flex-col gap-1 ${cls ?? ''}`}>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{Icon && <Icon className="w-3.5 h-3.5" />}{label}
</div>
<div className="text-2xl font-bold tabular-nums">{value}</div>
{sub && <div className="text-xs text-muted-foreground">{sub}</div>}
</div>
);
}
function StatusTab({ data, onSync, syncing }: { data: any; onSync: () => void; syncing: boolean }) {
if (!data) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
const devs = data.devices ?? {};
const alerts = data.openAlerts ?? {};
return (
<div className="space-y-6">
<div className="flex items-center justify-between rounded-lg border p-4 bg-muted/30">
<div className="space-y-0.5">
<p className="text-sm font-medium">{data.configured ? 'Connected' : 'Not configured'}</p>
<p className="text-xs text-muted-foreground">Last sync: {fmtDate(data.lastSync)}</p>
</div>
<div className="flex gap-2">
<a href="https://concord.rmm.datto.com" target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm" className="gap-2">
<ExternalLink className="w-4 h-4" />Portal
</Button>
</a>
<Button size="sm" onClick={onSync} disabled={syncing || !data.configured}>
{syncing ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Full Sync
</Button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="Sites" value={data.sites ?? 0} icon={Server} />
<StatCard label="Devices" value={devs.total ?? 0} icon={Monitor} />
<StatCard label="Online" value={devs.online ?? 0} icon={Wifi}
cls="border-green-500/30 bg-green-500/5" />
<StatCard label="Offline" value={devs.offline ?? 0} icon={WifiOff}
cls={(devs.offline ?? 0) > 0 ? 'border-yellow-500/30 bg-yellow-500/5' : ''} />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="Open Alerts" value={alerts.total ?? 0} icon={Bell}
cls={(alerts.total ?? 0) > 0 ? 'border-red-500/30 bg-red-500/5' : ''} />
<StatCard label="Critical / High" value={`${alerts.critical ?? 0} / ${alerts.high ?? 0}`} icon={XCircle}
cls={(alerts.critical ?? 0) > 0 ? 'border-red-500/30 bg-red-500/5' : ''} />
<StatCard label="Moderate" value={alerts.moderate ?? 0} icon={AlertTriangle} />
<StatCard label="With Ticket" value={alerts.withTicket ?? 0} icon={CheckCircle2}
sub="linked to Autotask" />
</div>
{(alerts.critical > 0 || alerts.high > 0) && (
<div className="rounded-lg border p-4 space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Attention Required</p>
{alerts.critical > 0 && (
<div className="flex items-center gap-2 text-sm text-red-600">
<XCircle className="w-4 h-4" />{alerts.critical} critical alert{alerts.critical !== 1 ? 's' : ''}
</div>
)}
{alerts.high > 0 && (
<div className="flex items-center gap-2 text-sm text-orange-600">
<AlertTriangle className="w-4 h-4" />{alerts.high} high priority alert{alerts.high !== 1 ? 's' : ''}
</div>
)}
</div>
)}
</div>
);
}
function HistoryTab({ refreshKey }: { refreshKey: number }) {
const [rows, setRows] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch('/api/sync/history?entityType=datto_rmm&limit=50')
.then(r => r.json())
.then(d => setRows(d.history ?? []))
.catch(() => setRows([]))
.finally(() => setLoading(false));
}, [refreshKey]);
if (loading) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
if (!rows.length) return <div className="text-center py-12 text-muted-foreground text-sm">No sync history yet run a sync to populate</div>;
return (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Records</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Started</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Duration</th>
</tr>
</thead>
<tbody>
{rows.map((row: any, i: number) => {
const dur = row.completed_at && row.started_at
? Math.round((new Date(row.completed_at).getTime() - new Date(row.started_at).getTime()) / 1000)
: null;
return (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 capitalize">{row.sync_type}</td>
<td className="px-4 py-2">
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
row.status === 'completed' ? 'bg-green-500/15 text-green-700' :
row.status === 'failed' ? 'bg-red-500/15 text-red-600' :
'bg-yellow-500/15 text-yellow-700'
}`}>{row.status}</span>
</td>
<td className="px-4 py-2 tabular-nums">{row.records_added ?? 0}</td>
<td className="px-4 py-2 text-muted-foreground">{fmtDate(row.started_at)}</td>
<td className="px-4 py-2 text-muted-foreground">{dur != null ? `${dur}s` : '—'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
export default function DattoRmmPage() {
const [status, setStatus] = useState<any>(null);
const [syncing, setSyncing] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const fetchStatus = async () => {
const res = await fetch('/api/integrations/status');
if (res.ok) { const d = await res.json(); setStatus(d.dattoRmm); }
};
useEffect(() => { fetchStatus(); }, [refreshKey]);
const handleSync = async () => {
setSyncing(true);
try {
await fetch('/api/datto-rmm/sync', {
method: 'POST',
body: JSON.stringify({ syncType: 'full' }),
headers: { 'Content-Type': 'application/json' },
});
const poll = setInterval(async () => {
const r = await fetch('/api/datto-rmm/sync');
if (r.ok) {
const d = await r.json();
if (!d.isSyncing) {
clearInterval(poll);
setSyncing(false);
setRefreshKey(k => k + 1);
}
}
}, 5000);
} catch {
setSyncing(false);
}
};
return (
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
<div className="flex items-center gap-4">
<Link href="/admin/sync">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
Integrations
</Button>
</Link>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg border border-orange-500/30 bg-orange-500/5">
<Monitor className="w-5 h-5 text-orange-500" />
</div>
<div>
<h1 className="text-2xl font-bold">RMM Datto RMM</h1>
<p className="text-sm text-muted-foreground">Sites, devices, alerts, patch management</p>
</div>
</div>
</div>
<Tabs defaultValue="status" className="w-full">
<TabsList className="grid w-full max-w-md grid-cols-3">
<TabsTrigger value="status" className="gap-2">
<Activity className="h-4 w-4" />Status
</TabsTrigger>
<TabsTrigger value="history" className="gap-2">
<History className="h-4 w-4" />History
</TabsTrigger>
<TabsTrigger value="about" className="gap-2">
<Monitor className="h-4 w-4" />About
</TabsTrigger>
</TabsList>
<TabsContent value="status" className="mt-6">
<StatusTab data={status} onSync={handleSync} syncing={syncing} />
</TabsContent>
<TabsContent value="history" className="mt-6">
<HistoryTab refreshKey={refreshKey} />
</TabsContent>
<TabsContent value="about" className="mt-6">
<div className="space-y-4 text-sm text-muted-foreground">
<div className="rounded-lg border p-4 space-y-2">
<p className="font-medium text-foreground">Synced Entities</p>
<ul className="space-y-1 list-disc list-inside">
<li><strong>Sites</strong> RMM sites with device counts, mapped to Autotask companies</li>
<li><strong>Devices</strong> all managed devices with OS, IP, AV, patch status, UDFs</li>
<li><strong>Open Alerts</strong> active alerts with priority, device context, ticket linkage</li>
<li><strong>Resolved Alerts</strong> recent resolved alerts with response action history</li>
</ul>
</div>
<div className="rounded-lg border p-4 space-y-2">
<p className="font-medium text-foreground">Authentication</p>
<p>OAuth2 password grant API key + secret Bearer token (100h TTL, refreshed at 50min)</p>
<p>Rate limit: 600 requests / 60 seconds across the account</p>
</div>
</div>
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -1,118 +1,263 @@
/**
* Admin Sync Page
* Main page for controlling and monitoring Autotask PostgreSQL sync operations
*/
'use client';
import { useState, useEffect } from 'react';
import SyncControlPanel from '@/components/admin/SyncControlPanel';
import SyncDashboard from '@/components/admin/SyncDashboard';
import SyncHistoryTable from '@/components/admin/SyncHistoryTable';
import SyncScheduler from '@/components/admin/SyncScheduler';
import { EntityType } from '@/lib/types/sync';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ArrowLeft, Home, Activity, History, Calendar } from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { RefreshCw, CheckCircle2, XCircle, AlertTriangle, Clock, Loader2, ChevronRight } from 'lucide-react';
export default function AdminSyncPage() {
const [selectedEntities, setSelectedEntities] = useState<EntityType[]>([]);
const [isSyncing, setIsSyncing] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
interface IntegrationCard {
id: string;
category: string;
product: string;
description: string;
href: string;
logo: string;
color: string;
}
// Auto-refresh during sync and check if sync completed
useEffect(() => {
if (isSyncing) {
const interval = setInterval(async () => {
setRefreshKey(prev => prev + 1);
const INTEGRATIONS: IntegrationCard[] = [
{ id: 'autotask', category: 'PSA', product: 'Autotask', description: 'Tickets, companies, contacts, time entries, configuration items', href: '/admin/sync/autotask', logo: '/logos/autotask.ico', color: 'red' },
{ id: 'veeam', category: 'Backup', product: 'Veeam VSPC', description: 'Agent jobs, backup jobs, protected workloads, compliance', href: '/admin/sync/veeam', logo: '/logos/veeam.ico', color: 'green' },
{ id: 'datto-rmm', category: 'RMM', product: 'Datto RMM', description: 'Device monitoring, alerts, patch management, remote access', href: '/admin/sync/datto-rmm', logo: '/logos/datto-rmm.ico', color: 'blue' },
{ id: 'auvik', category: 'NMS', product: 'Auvik', description: 'Network devices, topology, tenant mappings', href: '/admin/sync/auvik', logo: '/logos/auvik.ico', color: 'purple' },
{ id: 'addigy', category: 'Apple RMM', product: 'Addigy', description: 'macOS/iOS device management, policies, compliance', href: '/admin/sync/addigy', logo: '/logos/addigy.ico', color: 'gray' },
];
// Check if sync is still in progress
const COLOR_MAP: Record<string, { bg: string; border: string }> = {
red: { bg: 'bg-red-500/5', border: 'border-red-500/20' },
green: { bg: 'bg-green-500/5', border: 'border-green-500/20' },
blue: { bg: 'bg-blue-500/5', border: 'border-blue-500/20' },
purple: { bg: 'bg-purple-500/5', border: 'border-purple-500/20' },
gray: { bg: 'bg-muted/20', border: 'border-border' },
};
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`;
}
export default function SyncOverviewPage() {
const [status, setStatus] = useState<any>(null);
const [autotaskSync, setAutotaskSync] = useState<any>(null);
const [loading, setLoading] = useState(true);
const fetchAll = async () => {
try {
const response = await fetch('/api/sync/status');
if (response.ok) {
const data = await response.json();
// If no sync in progress, mark as complete
if (!data.inProgress) {
setIsSyncing(false);
const [intRes, atRes] = await Promise.all([
fetch('/api/integrations/status'),
fetch('/api/sync/last-sync'),
]);
if (intRes.ok) setStatus(await intRes.json());
if (atRes.ok) {
const d = await atRes.json();
setAutotaskSync(d.lastSync || {});
}
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
} catch (error) {
console.error('Failed to check sync status:', error);
}
}, 5000); // Refresh every 5 seconds
return () => clearInterval(interval);
}
}, [isSyncing]);
const handleSyncStart = () => {
setIsSyncing(true);
};
const handleSyncComplete = () => {
setIsSyncing(false);
setRefreshKey(prev => prev + 1);
useEffect(() => { fetchAll(); }, []);
const getAutotaskSummary = () => {
if (!autotaskSync) return null;
const entries = Object.values(autotaskSync) as any[];
if (!entries.length) return null;
const latest = entries.reduce((a: any, b: any) =>
new Date(a.completed_at) > new Date(b.completed_at) ? a : b
);
const failed = entries.filter((e: any) => e.status === 'failed').length;
return { lastSync: latest.completed_at, failed, total: entries.length };
};
const atSummary = getAutotaskSummary();
const getSummary = (id: string) => {
if (!status) return null;
if (id === 'autotask') return atSummary;
if (id === 'veeam') {
const v = status.veeam;
if (!v) return null;
const aj = v.agentJobs ?? {};
const bj = v.backupJobs ?? {};
return {
lastSync: v.lastSync,
failed: (aj.failed ?? 0) + (bj.failed ?? 0),
warning: (aj.warning ?? 0) + (bj.warning ?? 0),
running: aj.running ?? 0,
total: (aj.total ?? 0) + (bj.total ?? 0),
};
}
if (id === 'datto-rmm') {
const d = status.dattoRmm;
if (!d) return null;
return {
lastSync: d.lastSync,
sites: d.sites ?? 0,
devices: d.devices?.total ?? 0,
online: d.devices?.online ?? 0,
offline: d.devices?.offline ?? 0,
openAlerts: d.openAlerts?.total ?? 0,
critical: d.openAlerts?.critical ?? 0,
};
}
if (id === 'auvik') return { lastSync: status.auvik?.lastSync, configured: status.auvik?.configured };
if (id === 'addigy') return { lastSync: status.addigy?.lastSync, configured: status.addigy?.configured };
return null;
};
const getStatusIcon = (id: string, summary: any) => {
if (!summary) return <Clock className="w-4 h-4 text-muted-foreground" />;
if (id === 'veeam') {
if (summary.failed > 0) return <XCircle className="w-4 h-4 text-red-500" />;
if (summary.warning > 0 || summary.running > 0) return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
}
if (id === 'autotask') {
if (summary.failed > 0) return <XCircle className="w-4 h-4 text-red-500" />;
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
}
if (id === 'datto-rmm') {
if (summary.critical > 0) return <XCircle className="w-4 h-4 text-red-500" />;
if (summary.openAlerts > 0) return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
}
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
};
return (
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6 md:space-y-8">
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
<Home className="w-4 h-4" />
<span className="hidden sm:inline">Back to Dashboard</span>
</Button>
</Link>
<div>
<h1 className="text-2xl md:text-3xl font-bold">Autotask Sync</h1>
<p className="text-sm md:text-base text-muted-foreground mt-1">
Sync Autotask data to PostgreSQL database
<h1 className="text-2xl md:text-3xl font-bold">Integrations & Sync</h1>
<p className="text-sm text-muted-foreground mt-0.5">Manage data sync across all connected platforms</p>
</div>
<Button variant="outline" size="sm" onClick={fetchAll} className="gap-2">
<RefreshCw className="w-4 h-4" />
Refresh
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 items-stretch">
{INTEGRATIONS.map((intg) => {
const colors = COLOR_MAP[intg.color];
const summary = getSummary(intg.id);
return (
<Link key={intg.id} href={intg.href} className="flex">
<div className={`flex flex-col w-full rounded-xl border ${colors.border} ${colors.bg} p-5 hover:shadow-md transition-all cursor-pointer group`}>
{/* Header: logo + names + chevron */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<img src={intg.logo} alt={intg.product} className="w-10 h-10 shrink-0 rounded-lg object-contain" />
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground leading-none mb-1">
{intg.category}
</p>
<div className="flex items-center gap-2">
<span className="font-bold text-base leading-tight">{intg.product}</span>
{summary && getStatusIcon(intg.id, summary)}
</div>
</div>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors shrink-0 mt-1" />
</div>
{/* Sync Control Panel */}
<SyncControlPanel
selectedEntities={selectedEntities}
onSelectedEntitiesChange={setSelectedEntities}
onSyncStart={handleSyncStart}
onSyncComplete={handleSyncComplete}
isSyncing={isSyncing}
/>
{/* Description */}
<p className="text-xs text-muted-foreground leading-relaxed mb-4">{intg.description}</p>
{/* Tabs for Sync Status, History, and Schedules */}
<Tabs defaultValue="status" className="w-full">
<TabsList className="grid w-full max-w-2xl grid-cols-3">
<TabsTrigger value="status" className="gap-2">
<Activity className="h-4 w-4" />
Sync Status
</TabsTrigger>
<TabsTrigger value="history" className="gap-2">
<History className="h-4 w-4" />
Sync History
</TabsTrigger>
<TabsTrigger value="schedules" className="gap-2">
<Calendar className="h-4 w-4" />
Schedules
</TabsTrigger>
</TabsList>
<TabsContent value="status" className="mt-6">
<SyncDashboard refreshKey={refreshKey} />
</TabsContent>
<TabsContent value="history" className="mt-6">
<SyncHistoryTable refreshKey={refreshKey} />
</TabsContent>
<TabsContent value="schedules" className="mt-6">
<SyncScheduler />
</TabsContent>
</Tabs>
{/* Stats — pushed to bottom */}
<div className="mt-auto pt-3 border-t border-border/50 space-y-1.5 text-xs text-muted-foreground">
{intg.id === 'autotask' && summary && (
<>
<div className="flex justify-between">
<span>Last sync</span>
<span className="font-medium text-foreground">{fmtDate(summary.lastSync)}</span>
</div>
<div className="flex justify-between">
<span>Entities tracked</span>
<span className="font-medium text-foreground">{summary.total}</span>
</div>
</>
)}
{intg.id === 'veeam' && summary && (
<>
<div className="flex justify-between">
<span>Last sync</span>
<span className="font-medium text-foreground">{fmtDate(summary.lastSync)}</span>
</div>
<div className="flex justify-between">
<span>Total jobs</span>
<span className="font-medium text-foreground">{summary.total}</span>
</div>
{(summary as any).failed > 0 && (
<div className="flex justify-between text-red-600">
<span>Failed</span><span className="font-medium">{(summary as any).failed}</span>
</div>
)}
{(summary as any).running > 0 && (
<div className="flex justify-between text-blue-600">
<span>Running (stalled?)</span><span className="font-medium">{(summary as any).running}</span>
</div>
)}
{(summary as any).warning > 0 && (
<div className="flex justify-between text-yellow-700">
<span>Warning</span><span className="font-medium">{(summary as any).warning}</span>
</div>
)}
</>
)}
{intg.id === 'datto-rmm' && summary && (
<>
<div className="flex justify-between">
<span>Last sync</span>
<span className="font-medium text-foreground">{fmtDate(summary.lastSync)}</span>
</div>
<div className="flex justify-between">
<span>Sites / Devices</span>
<span className="font-medium text-foreground">{(summary as any).sites} / {(summary as any).devices}</span>
</div>
<div className="flex justify-between">
<span>Online / Offline</span>
<span className="font-medium text-foreground">{(summary as any).online} / {(summary as any).offline}</span>
</div>
{(summary as any).openAlerts > 0 && (
<div className={`flex justify-between ${(summary as any).critical > 0 ? 'text-red-600' : 'text-yellow-700'}`}>
<span>Open alerts</span>
<span className="font-medium">{(summary as any).openAlerts}</span>
</div>
)}
</>
)}
{(intg.id === 'auvik' || intg.id === 'addigy') && (
<div className="flex justify-between">
<span>Status</span>
<span className={`font-medium ${(summary as any)?.configured ? 'text-green-600' : 'text-muted-foreground'}`}>
{(summary as any)?.configured ? 'Connected' : 'Not configured'}
</span>
</div>
)}
</div>
</div>
</Link>
);
})}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,465 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
ArrowLeft, Activity, History, Calendar, Shield, RefreshCw, Loader2,
CheckCircle2, XCircle, AlertTriangle, Clock, Server, HardDrive,
Bot, Bell, ChevronDown, ChevronRight,
} from 'lucide-react';
import SyncScheduler from '@/components/admin/SyncScheduler';
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmtDate(d: string | null | undefined) {
if (!d) return 'Never';
return new Date(d).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function fmtDur(ms: number) {
if (ms < 60000) return `${Math.round(ms / 1000)}s`;
return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`;
}
function StatCard({ label, value, sub, icon: Icon, cls }: {
label: string; value: string | number; sub?: string; icon?: React.ElementType; cls?: string;
}) {
return (
<div className={`rounded-lg border p-4 flex flex-col gap-1 ${cls ?? ''}`}>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{Icon && <Icon className="w-3.5 h-3.5" />}{label}
</div>
<div className="text-2xl font-bold tabular-nums">{value}</div>
{sub && <div className="text-xs text-muted-foreground">{sub}</div>}
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const cls =
status === 'completed' ? 'bg-green-500/15 text-green-700' :
status === 'failed' ? 'bg-red-500/15 text-red-600' :
status === 'started' ? 'bg-blue-500/15 text-blue-700' :
'bg-yellow-500/15 text-yellow-700';
return <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}`}>{status}</span>;
}
// ── Status Tab ────────────────────────────────────────────────────────────────
function VeeamStatusTab({ data, onSync, syncing }: { data: any; onSync: (t: string) => void; syncing: boolean }) {
if (!data) return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
);
const aj = data.agentJobs ?? {};
const bj = data.backupJobs ?? {};
const totalFailed = (aj.failed ?? 0) + (bj.failed ?? 0);
const totalWarning = (aj.warning ?? 0) + (bj.warning ?? 0);
const totalRunning = aj.running ?? 0;
const totalSuccess = (aj.success ?? 0) + (bj.success ?? 0);
return (
<div className="space-y-6">
<div className="flex items-center justify-between rounded-lg border p-4 bg-muted/30">
<div className="space-y-0.5">
<p className="text-sm font-medium">{data.configured ? 'Connected to VSPC' : 'Not configured'}</p>
<p className="text-xs text-muted-foreground">Last sync: {fmtDate(data.lastSync)}</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => onSync('incremental')} disabled={syncing || !data.configured}>
{syncing ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Incremental
</Button>
<Button size="sm" onClick={() => onSync('full')} disabled={syncing || !data.configured}>
{syncing ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Full Sync
</Button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="Organizations" value={data.organizations ?? 0} icon={Server} />
<StatCard label="Protected Workloads" value={data.protectedWorkloads ?? 0} icon={HardDrive} />
<StatCard label="Agent Jobs" value={aj.total ?? 0} sub={`${aj.success ?? 0} success`} icon={Shield} />
<StatCard label="Backup Jobs" value={bj.total ?? 0} sub={`${bj.success ?? 0} success`} icon={Shield} />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="Running" value={totalRunning} icon={Clock}
cls={totalRunning > 0 ? 'border-blue-500/30 bg-blue-500/5' : ''} />
<StatCard label="Failed" value={totalFailed} icon={XCircle}
cls={totalFailed > 0 ? 'border-red-500/30 bg-red-500/5' : ''} />
<StatCard label="Warning" value={totalWarning} icon={AlertTriangle}
cls={totalWarning > 0 ? 'border-yellow-500/30 bg-yellow-500/5' : ''} />
<StatCard label="Success" value={totalSuccess} icon={CheckCircle2}
cls="border-green-500/30 bg-green-500/5" />
</div>
{(totalFailed > 0 || totalWarning > 0 || totalRunning > 0) && (
<div className="rounded-lg border p-4 space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Attention Required</p>
{totalFailed > 0 && <div className="flex items-center gap-2 text-sm text-red-600"><XCircle className="w-4 h-4" />{totalFailed} job{totalFailed !== 1 ? 's' : ''} failed</div>}
{totalWarning > 0 && <div className="flex items-center gap-2 text-sm text-yellow-700"><AlertTriangle className="w-4 h-4" />{totalWarning} job{totalWarning !== 1 ? 's' : ''} with warnings</div>}
{totalRunning > 0 && <div className="flex items-center gap-2 text-sm text-blue-600"><Loader2 className="w-4 h-4 animate-spin" />{totalRunning} job{totalRunning !== 1 ? 's' : ''} running</div>}
</div>
)}
</div>
);
}
// ── History Tab ───────────────────────────────────────────────────────────────
const ENTITY_LABELS: Record<string, string> = {
organizations: 'Organizations',
backup_servers: 'Backup Servers',
repositories: 'Repositories',
backup_jobs: 'Backup Jobs',
backup_agent_jobs: 'Agent Jobs',
protected_workloads:'Protected Workloads',
backup_agents: 'Agents',
alarms: 'Alarms',
};
function HistoryRow({ row }: { row: any }) {
const [expanded, setExpanded] = useState(false);
const dur = row.completed_at && row.started_at
? new Date(row.completed_at).getTime() - new Date(row.started_at).getTime()
: null;
let entities: Array<{ entity: string; success: boolean; recordsUpserted: number; duration: number; error?: string }> = [];
try {
if (row.entity_details) {
entities = typeof row.entity_details === 'string' ? JSON.parse(row.entity_details) : row.entity_details;
}
} catch { /* ignore parse errors */ }
return (
<>
<tr
className={`border-b hover:bg-muted/30 ${entities.length > 0 ? 'cursor-pointer' : ''}`}
onClick={() => entities.length > 0 && setExpanded(e => !e)}
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-1.5">
{entities.length > 0
? (expanded
? <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
: <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />)
: <span className="w-3.5" />}
<span className="capitalize">{row.sync_type}</span>
</div>
</td>
<td className="px-4 py-2.5"><StatusBadge status={row.status} /></td>
<td className="px-4 py-2.5 tabular-nums font-medium">{(row.records_added ?? 0).toLocaleString()}</td>
<td className="px-4 py-2.5 text-muted-foreground text-xs">{fmtDate(row.started_at)}</td>
<td className="px-4 py-2.5 text-muted-foreground">{dur != null ? fmtDur(dur) : '—'}</td>
<td className="px-4 py-2.5 text-muted-foreground capitalize">{row.triggered_by ?? '—'}</td>
</tr>
{expanded && entities.length > 0 && (
<tr className="border-b bg-muted/20">
<td colSpan={6} className="px-8 py-3">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">Entity Breakdown</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{entities.map((e) => (
<div key={e.entity} className={`rounded border px-3 py-2 text-xs ${e.success ? '' : 'border-red-300 bg-red-50 dark:bg-red-950/20'}`}>
<div className="font-medium text-foreground">{ENTITY_LABELS[e.entity] ?? e.entity}</div>
<div className="text-muted-foreground mt-0.5">
{e.success
? <><span className="text-green-700 font-semibold">{e.recordsUpserted.toLocaleString()}</span> records · {fmtDur(e.duration)}</>
: <span className="text-red-600">Failed: {e.error?.substring(0, 60)}</span>}
</div>
</div>
))}
</div>
{row.error_message && (
<div className="mt-2 text-xs text-red-600 bg-red-50 dark:bg-red-950/20 border border-red-200 rounded px-3 py-2">
{row.error_message}
</div>
)}
</td>
</tr>
)}
</>
);
}
function VeeamHistoryTab({ refreshKey }: { refreshKey: number }) {
const [rows, setRows] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch('/api/sync/history?entityType=veeam&limit=50')
.then(r => r.json())
.then(d => setRows(d.history ?? []))
.catch(() => setRows([]))
.finally(() => setLoading(false));
}, [refreshKey]);
if (loading) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
if (!rows.length) return <div className="text-center py-12 text-muted-foreground text-sm">No sync history yet run a sync to populate</div>;
return (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Records</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Started</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Duration</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Triggered By</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => <HistoryRow key={i} row={row} />)}
</tbody>
</table>
</div>
);
}
// ── Agents Tab ────────────────────────────────────────────────────────────────
function AgentsTab({ refreshKey }: { refreshKey: number }) {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch('/api/veeam/agents')
.then(r => r.json())
.then(d => setData(d))
.catch(() => setData(null))
.finally(() => setLoading(false));
}, [refreshKey]);
if (loading) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
if (!data || !data.agents?.length) return <div className="text-center py-12 text-muted-foreground text-sm">No agent data run a sync first</div>;
const s = data.summary ?? {};
const agents: any[] = data.agents ?? [];
return (
<div className="space-y-6">
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<StatCard label="Total Agents" value={s.total ?? 0} icon={Bot} />
<StatCard label="Active" value={s.active ?? 0} icon={CheckCircle2} cls="border-green-500/30 bg-green-500/5" />
<StatCard label="Inaccessible" value={s.inaccessible ?? 0} icon={XCircle}
cls={(s.inaccessible ?? 0) > 0 ? 'border-red-500/30 bg-red-500/5' : ''} />
<StatCard label="Outdated" value={s.outdated ?? 0} icon={AlertTriangle}
cls={(s.outdated ?? 0) > 0 ? 'border-yellow-500/30 bg-yellow-500/5' : ''} />
<StatCard label="Win / Linux / Mac" value={`${s.windows ?? 0} / ${s.linux ?? 0} / ${s.mac ?? 0}`} icon={Server} />
</div>
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Name</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Organization</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Platform</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Agent Status</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Version</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Mode</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Jobs</th>
</tr>
</thead>
<tbody>
{agents.map((a: any) => (
<tr key={a.instance_uid} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 font-medium">{a.name}</td>
<td className="px-4 py-2 text-muted-foreground text-xs">{a.organization_name ?? '—'}</td>
<td className="px-4 py-2 text-xs">{a.agent_platform ?? '—'}</td>
<td className="px-4 py-2">
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
a.status === 'Active' ? 'bg-green-500/15 text-green-700' : 'bg-muted text-muted-foreground'
}`}>{a.status ?? '—'}</span>
</td>
<td className="px-4 py-2">
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
a.management_agent_status === 'Inaccessible' ? 'bg-red-500/15 text-red-600' :
a.management_agent_status === 'Accessible' ? 'bg-green-500/15 text-green-700' :
'bg-muted text-muted-foreground'
}`}>{a.management_agent_status ?? '—'}</span>
</td>
<td className="px-4 py-2 text-xs">
<span className={a.version_status === 'Outdated' ? 'text-yellow-700 font-medium' : 'text-muted-foreground'}>
{a.version ?? '—'}
{a.version_status === 'Outdated' && ' ⚠'}
</span>
</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{a.operation_mode ?? '—'}</td>
<td className="px-4 py-2 text-xs tabular-nums">
<span className="text-green-700">{a.success_jobs_count ?? 0}</span>
{(a.running_jobs_count ?? 0) > 0 && <span className="text-blue-600 ml-1">{a.running_jobs_count}</span>}
{(a.total_jobs_count ?? 0) - (a.success_jobs_count ?? 0) - (a.running_jobs_count ?? 0) > 0 && (
<span className="text-red-600 ml-1">
{(a.total_jobs_count ?? 0) - (a.success_jobs_count ?? 0) - (a.running_jobs_count ?? 0)}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ── Alarms Tab ────────────────────────────────────────────────────────────────
function AlarmsTab({ refreshKey }: { refreshKey: number }) {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch('/api/veeam/alarms')
.then(r => r.json())
.then(d => setData(d))
.catch(() => setData(null))
.finally(() => setLoading(false));
}, [refreshKey]);
if (loading) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
if (!data || !data.alarms?.length) return <div className="text-center py-12 text-muted-foreground text-sm">No alarm data run a sync first</div>;
const s = data.summary ?? {};
const alarms: any[] = data.alarms ?? [];
return (
<div className="space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="Total Alarms" value={s.total ?? 0} icon={Bell} />
<StatCard label="Active" value={s.statusActive ?? 0} icon={XCircle}
cls={(s.statusActive ?? 0) > 0 ? 'border-red-500/30 bg-red-500/5' : ''} />
<StatCard label="Warning" value={s.statusWarning ?? 0} icon={AlertTriangle}
cls={(s.statusWarning ?? 0) > 0 ? 'border-yellow-500/30 bg-yellow-500/5' : ''} />
<StatCard label="Resolved" value={s.statusResolved ?? 0} icon={CheckCircle2}
cls="border-green-500/30 bg-green-500/5" />
</div>
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Object</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Organization</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Repeats</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Last Activation</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Message</th>
</tr>
</thead>
<tbody>
{alarms.map((a: any) => (
<tr key={a.instance_uid} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 font-medium">{a.object_computer_name || a.object_name || '—'}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{a.object_type ?? '—'}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{a.organization_name ?? '—'}</td>
<td className="px-4 py-2">
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
a.last_activation_status === 'Active' ? 'bg-red-500/15 text-red-600' :
a.last_activation_status === 'Warning' ? 'bg-yellow-500/15 text-yellow-700' :
a.last_activation_status === 'Resolved' ? 'bg-green-500/15 text-green-700' :
'bg-muted text-muted-foreground'
}`}>{a.last_activation_status ?? '—'}</span>
</td>
<td className="px-4 py-2 tabular-nums text-xs">{a.repeat_count ?? 0}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{fmtDate(a.last_activation_time)}</td>
<td className="px-4 py-2 text-xs text-muted-foreground max-w-xs truncate" title={a.last_activation_message ?? ''}>
{a.last_activation_message?.trim() || '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function VeeamSyncPage() {
const [status, setStatus] = useState<any>(null);
const [syncing, setSyncing] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const fetchStatus = async () => {
const res = await fetch('/api/integrations/status');
if (res.ok) { const d = await res.json(); setStatus(d.veeam); }
};
useEffect(() => { fetchStatus(); }, [refreshKey]);
const handleSync = async (syncType = 'full') => {
setSyncing(true);
try {
await fetch('/api/veeam/sync', {
method: 'POST',
body: JSON.stringify({ syncType }),
headers: { 'Content-Type': 'application/json' },
});
const poll = setInterval(async () => {
try {
const r = await fetch('/api/veeam/sync');
if (r.ok) {
const d = await r.json();
if (!d.isSyncing) {
clearInterval(poll);
setSyncing(false);
setRefreshKey(k => k + 1);
}
}
} catch { /* keep polling */ }
}, 4000);
} catch {
setSyncing(false);
}
};
return (
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
<div className="flex items-center gap-4">
<Link href="/admin/sync">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />Integrations
</Button>
</Link>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg border border-green-500/30 bg-green-500/5">
<Shield className="w-5 h-5 text-green-500" />
</div>
<div>
<h1 className="text-2xl font-bold">Backup Veeam VSPC</h1>
<p className="text-sm text-muted-foreground">Agent jobs, backup jobs, protected workloads, agents, alarms</p>
</div>
</div>
<div className="ml-auto">
<Link href="/backup-status">
<Button variant="outline" size="sm">View Backup Status</Button>
</Link>
</div>
</div>
<Tabs defaultValue="status" className="w-full">
<TabsList className="grid w-full max-w-2xl grid-cols-5">
<TabsTrigger value="status" className="gap-1.5"><Activity className="h-4 w-4" />Status</TabsTrigger>
<TabsTrigger value="history" className="gap-1.5"><History className="h-4 w-4" />History</TabsTrigger>
<TabsTrigger value="agents" className="gap-1.5"><Bot className="h-4 w-4" />Agents</TabsTrigger>
<TabsTrigger value="alarms" className="gap-1.5"><Bell className="h-4 w-4" />Alarms</TabsTrigger>
<TabsTrigger value="schedules" className="gap-1.5"><Calendar className="h-4 w-4" />Schedules</TabsTrigger>
</TabsList>
<TabsContent value="status" className="mt-6"><VeeamStatusTab data={status} onSync={handleSync} syncing={syncing} /></TabsContent>
<TabsContent value="history" className="mt-6"><VeeamHistoryTab refreshKey={refreshKey} /></TabsContent>
<TabsContent value="agents" className="mt-6"><AgentsTab refreshKey={refreshKey} /></TabsContent>
<TabsContent value="alarms" className="mt-6"><AlarmsTab refreshKey={refreshKey} /></TabsContent>
<TabsContent value="schedules" className="mt-6"><SyncScheduler /></TabsContent>
</Tabs>
</div>
);
}

View file

@ -0,0 +1,510 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
ArrowLeft,
Bot,
Plus,
Pencil,
Trash2,
Loader2,
GitBranch,
Tag,
AlertTriangle,
Layers,
Route,
} from 'lucide-react';
import { toast } from 'sonner';
import { ClassificationRule, RuleType, MatchField, MatchOperator, ConfidenceLevel } from '@/lib/types/workflow';
const RULE_TYPES: { value: RuleType; label: string; icon: any; description: string }[] = [
{ value: 'branch_routing', label: 'Branch Routing', icon: GitBranch, description: 'Route tickets to NOC, SOC, or Service Desk' },
{ value: 'ticket_type', label: 'Ticket Type', icon: Tag, description: 'Classify as Incident or Service Request' },
{ value: 'issue_classification', label: 'Issue Classification', icon: Layers, description: 'Assign Issue Type and Sub-Issue Type' },
{ value: 'priority', label: 'Priority', icon: AlertTriangle, description: 'Set ticket priority level' },
{ value: 'queue_routing', label: 'Queue Routing', icon: Route, description: 'Route to the appropriate queue' },
];
const MATCH_FIELDS: { value: MatchField; label: string }[] = [
{ value: 'title', label: 'Title' },
{ value: 'description', label: 'Description' },
{ value: 'title_or_description', label: 'Title or Description' },
{ value: 'ticket_category', label: 'Ticket Category' },
{ value: 'ticket_type', label: 'Ticket Type' },
{ value: 'priority', label: 'Priority' },
{ value: 'policy_name', label: 'Policy Name' },
{ value: 'device_name', label: 'Device Name' },
{ value: 'creator_resource_id', label: 'Creator Resource ID' },
{ value: 'person_id', label: 'Person ID' },
{ value: 'company_id', label: 'Company ID' },
];
const MATCH_OPERATORS: { value: MatchOperator; label: string }[] = [
{ value: 'contains', label: 'Contains (any of)' },
{ value: 'starts_with', label: 'Starts With' },
{ value: 'regex', label: 'Regex' },
{ value: 'equals', label: 'Equals' },
{ value: 'in', label: 'In List' },
{ value: 'not_in', label: 'Not In List' },
];
const defaultRule: Partial<ClassificationRule> = {
name: '',
description: '',
rule_type: 'branch_routing',
sort_order: 0,
is_active: true,
match_field: 'title_or_description',
match_operator: 'contains',
match_value: [],
match_case_sensitive: false,
result_field: 'branch',
result_value: '',
result_field_2: null,
result_value_2: null,
confidence: 'high',
stop_on_match: true,
};
export default function ClassificationRulesPage() {
const [rules, setRules] = useState<ClassificationRule[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<RuleType>('branch_routing');
const [editingRule, setEditingRule] = useState<Partial<ClassificationRule> | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [matchValueText, setMatchValueText] = useState('');
useEffect(() => {
loadRules();
}, []);
const loadRules = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/workflow/classification-rules');
if (res.ok) {
setRules(await res.json());
}
} catch (error) {
toast.error('Failed to load classification rules');
} finally {
setIsLoading(false);
}
};
const handleCreate = () => {
const newRule = { ...defaultRule, rule_type: activeTab };
// Set default result field based on rule type
switch (activeTab) {
case 'branch_routing': newRule.result_field = 'branch'; break;
case 'ticket_type': newRule.result_field = 'ticket_type'; break;
case 'issue_classification': newRule.result_field = 'issue_type'; newRule.result_field_2 = 'sub_issue_type'; break;
case 'priority': newRule.result_field = 'priority'; break;
case 'queue_routing': newRule.result_field = 'queue_id'; break;
}
setEditingRule(newRule);
setMatchValueText(Array.isArray(newRule.match_value) ? newRule.match_value.join('\n') : String(newRule.match_value || ''));
};
const handleEdit = (rule: ClassificationRule) => {
setEditingRule({ ...rule });
const mv = rule.match_value;
setMatchValueText(Array.isArray(mv) ? mv.join('\n') : String(mv || ''));
};
const handleSave = async () => {
if (!editingRule) return;
setIsSaving(true);
try {
// Parse match value based on operator
let parsedMatchValue: any = matchValueText;
if (['contains', 'in', 'not_in'].includes(editingRule.match_operator || '')) {
parsedMatchValue = matchValueText.split('\n').map(s => s.trim()).filter(Boolean);
}
const payload = {
...editingRule,
match_value: parsedMatchValue,
};
const isUpdate = 'id' in editingRule && editingRule.id;
const url = isUpdate
? `/api/workflow/classification-rules/${editingRule.id}`
: '/api/workflow/classification-rules';
const res = await fetch(url, {
method: isUpdate ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.ok) {
toast.success(isUpdate ? 'Rule updated' : 'Rule created');
setEditingRule(null);
loadRules();
} else {
toast.error('Failed to save rule');
}
} catch (error) {
toast.error('Failed to save rule');
} finally {
setIsSaving(false);
}
};
const handleDelete = async (id: number) => {
if (!confirm('Delete this classification rule?')) return;
try {
const res = await fetch(`/api/workflow/classification-rules/${id}`, { method: 'DELETE' });
if (res.ok) {
toast.success('Rule deleted');
loadRules();
}
} catch {
toast.error('Failed to delete rule');
}
};
const handleToggleActive = async (rule: ClassificationRule) => {
try {
await fetch(`/api/workflow/classification-rules/${rule.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !rule.is_active }),
});
loadRules();
} catch {
toast.error('Failed to toggle rule');
}
};
const filteredRules = rules.filter(r => r.rule_type === activeTab);
const formatMatchValue = (value: any): string => {
if (Array.isArray(value)) return value.join(', ');
return String(value);
};
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/workflow">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<Bot className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">Classification Rules</h1>
<p className="text-sm text-muted-foreground">DB-driven keyword classification for ticket triage</p>
</div>
</div>
</div>
{/* Tabs by rule type */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as RuleType)}>
<TabsList className="grid grid-cols-5 w-full">
{RULE_TYPES.map((rt) => (
<TabsTrigger key={rt.value} value={rt.value} className="text-xs sm:text-sm">
<rt.icon className="w-4 h-4 mr-1 hidden sm:inline" />
{rt.label}
</TabsTrigger>
))}
</TabsList>
{RULE_TYPES.map((rt) => (
<TabsContent key={rt.value} value={rt.value}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<rt.icon className="w-5 h-5" />
{rt.label} Rules
</CardTitle>
<CardDescription>{rt.description}</CardDescription>
</div>
<Button onClick={handleCreate} size="sm">
<Plus className="w-4 h-4 mr-2" />
New Rule
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
) : filteredRules.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No {rt.label.toLowerCase()} rules configured.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Order</TableHead>
<TableHead>Name</TableHead>
<TableHead>Pattern</TableHead>
<TableHead>Result</TableHead>
<TableHead className="w-24">Confidence</TableHead>
<TableHead className="w-20">Active</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRules.map((rule) => (
<TableRow key={rule.id}>
<TableCell className="font-mono text-sm">{rule.sort_order}</TableCell>
<TableCell className="font-medium">{rule.name}</TableCell>
<TableCell>
<div className="text-xs space-y-1">
<Badge variant="outline" className="text-xs">{rule.match_field}</Badge>
<Badge variant="secondary" className="text-xs ml-1">{rule.match_operator}</Badge>
<div className="text-muted-foreground truncate max-w-xs" title={formatMatchValue(rule.match_value)}>
{formatMatchValue(rule.match_value)}
</div>
</div>
</TableCell>
<TableCell>
<div className="text-sm">
<span className="font-medium">{rule.result_field}:</span> {String(rule.result_value)}
{rule.result_field_2 && (
<div className="text-xs text-muted-foreground">
{rule.result_field_2}: {String(rule.result_value_2)}
</div>
)}
</div>
</TableCell>
<TableCell>
<Badge variant={
rule.confidence === 'high' ? 'default' :
rule.confidence === 'medium' ? 'secondary' : 'outline'
}>
{rule.confidence}
</Badge>
</TableCell>
<TableCell>
<Switch
checked={rule.is_active}
onCheckedChange={() => handleToggleActive(rule)}
/>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => handleEdit(rule)}>
<Pencil className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(rule.id)}>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
))}
</Tabs>
{/* Edit/Create Dialog */}
<Dialog open={!!editingRule} onOpenChange={(open) => !open && setEditingRule(null)}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingRule?.id ? 'Edit' : 'New'} Classification Rule</DialogTitle>
<DialogDescription>Configure pattern matching and classification result</DialogDescription>
</DialogHeader>
{editingRule && (
<div className="space-y-4">
{/* Basic Info */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={editingRule.name || ''}
onChange={(e) => setEditingRule({ ...editingRule, name: e.target.value })}
placeholder="Rule name"
/>
</div>
<div className="space-y-2">
<Label>Sort Order</Label>
<Input
type="number"
value={editingRule.sort_order ?? 0}
onChange={(e) => setEditingRule({ ...editingRule, sort_order: parseInt(e.target.value) })}
/>
</div>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input
value={editingRule.description || ''}
onChange={(e) => setEditingRule({ ...editingRule, description: e.target.value })}
placeholder="Optional description"
/>
</div>
{/* Pattern Matching */}
<div className="border rounded-lg p-4 space-y-4">
<h4 className="font-medium">Pattern Matching</h4>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Match Field</Label>
<Select
value={editingRule.match_field}
onValueChange={(v) => setEditingRule({ ...editingRule, match_field: v as MatchField })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{MATCH_FIELDS.map((f) => (
<SelectItem key={f.value} value={f.value}>{f.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Operator</Label>
<Select
value={editingRule.match_operator}
onValueChange={(v) => setEditingRule({ ...editingRule, match_operator: v as MatchOperator })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{MATCH_OPERATORS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>
Match Values
{['contains', 'in', 'not_in'].includes(editingRule.match_operator || '') && (
<span className="text-xs text-muted-foreground ml-2">(one per line)</span>
)}
</Label>
<Textarea
value={matchValueText}
onChange={(e) => setMatchValueText(e.target.value)}
placeholder={['contains', 'in', 'not_in'].includes(editingRule.match_operator || '')
? 'keyword1\nkeyword2\nkeyword3'
: 'value'
}
rows={4}
/>
</div>
</div>
{/* Result */}
<div className="border rounded-lg p-4 space-y-4">
<h4 className="font-medium">Classification Result</h4>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Result Field</Label>
<Input
value={editingRule.result_field || ''}
onChange={(e) => setEditingRule({ ...editingRule, result_field: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Result Value</Label>
<Input
value={String(editingRule.result_value || '')}
onChange={(e) => setEditingRule({ ...editingRule, result_value: e.target.value })}
/>
</div>
</div>
{editingRule.rule_type === 'issue_classification' && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Secondary Field</Label>
<Input
value={editingRule.result_field_2 || ''}
onChange={(e) => setEditingRule({ ...editingRule, result_field_2: e.target.value })}
placeholder="sub_issue_type"
/>
</div>
<div className="space-y-2">
<Label>Secondary Value</Label>
<Input
value={String(editingRule.result_value_2 || '')}
onChange={(e) => setEditingRule({ ...editingRule, result_value_2: e.target.value })}
/>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Confidence</Label>
<Select
value={editingRule.confidence}
onValueChange={(v) => setEditingRule({ ...editingRule, confidence: v as ConfidenceLevel })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2 pb-2">
<Switch
checked={editingRule.stop_on_match ?? true}
onCheckedChange={(v) => setEditingRule({ ...editingRule, stop_on_match: v })}
/>
<Label>Stop on match</Label>
</div>
</div>
</div>
{/* Save */}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setEditingRule(null)}>Cancel</Button>
<Button onClick={handleSave} disabled={isSaving || !editingRule.name}>
{isSaving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{editingRule.id ? 'Update' : 'Create'} Rule
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,314 @@
'use client';
import { useState, useEffect, Suspense } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
ArrowLeft,
History,
CheckCircle2,
XCircle,
Clock,
Activity,
Loader2,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { WorkflowExecutionWithSteps } from '@/lib/types/workflow';
export default function ExecutionHistoryPage() {
return (
<Suspense>
<ExecutionHistoryContent />
</Suspense>
);
}
function ExecutionHistoryContent() {
const searchParams = useSearchParams();
const highlightId = searchParams.get('id');
const [executions, setExecutions] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [methodFilter, setMethodFilter] = useState<string>('all');
const [isLoading, setIsLoading] = useState(true);
const [selectedExecution, setSelectedExecution] = useState<WorkflowExecutionWithSteps | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const pageSize = 25;
useEffect(() => { loadExecutions(); }, [page, statusFilter, methodFilter]);
const loadExecutions = async () => {
setIsLoading(true);
try {
const params = new URLSearchParams({
limit: pageSize.toString(),
offset: (page * pageSize).toString(),
});
if (statusFilter !== 'all') params.set('status', statusFilter);
if (methodFilter !== 'all') params.set('method', methodFilter);
const res = await fetch(`/api/workflow/executions?${params}`);
if (res.ok) {
const data = await res.json();
setExecutions(data.data || []);
setTotal(data.total || 0);
}
} catch (error) {
console.error('Failed to load executions:', error);
} finally {
setIsLoading(false);
}
};
const loadExecutionDetail = async (id: number) => {
setDetailLoading(true);
try {
const res = await fetch(`/api/workflow/executions/${id}`);
if (res.ok) {
setSelectedExecution(await res.json());
}
} catch (error) {
console.error('Failed to load execution detail:', error);
} finally {
setDetailLoading(false);
}
};
const statusIcon = (status: string) => {
switch (status) {
case 'completed': return <CheckCircle2 className="w-4 h-4 text-green-500" />;
case 'failed': return <XCircle className="w-4 h-4 text-red-500" />;
case 'skipped': return <Clock className="w-4 h-4 text-gray-500" />;
case 'running': return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />;
default: return <Clock className="w-4 h-4 text-gray-400" />;
}
};
const methodBadge = (method: string | null) => {
if (!method) return <Badge variant="outline" className="text-xs">n/a</Badge>;
const variant = method === 'robotic' ? 'default' : method === 'ai' ? 'secondary' : 'outline';
return <Badge variant={variant} className="text-xs">{method}</Badge>;
};
const totalPages = Math.ceil(total / pageSize);
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-3">
<Link href="/admin/workflow">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<History className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">Execution History</h1>
<p className="text-sm text-muted-foreground">{total} total executions</p>
</div>
</div>
{/* Filters */}
<div className="flex gap-4">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-40"><SelectValue placeholder="Status" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="failed">Failed</SelectItem>
<SelectItem value="skipped">Skipped</SelectItem>
<SelectItem value="running">Running</SelectItem>
</SelectContent>
</Select>
<Select value={methodFilter} onValueChange={setMethodFilter}>
<SelectTrigger className="w-40"><SelectValue placeholder="Method" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Methods</SelectItem>
<SelectItem value="robotic">Robotic</SelectItem>
<SelectItem value="ai">AI</SelectItem>
<SelectItem value="hybrid">Hybrid</SelectItem>
</SelectContent>
</Select>
</div>
<Card>
<CardContent className="pt-6">
{isLoading ? (
<div className="flex justify-center py-8"><Loader2 className="w-6 h-6 animate-spin" /></div>
) : executions.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No executions found.</p>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">Status</TableHead>
<TableHead>Ticket</TableHead>
<TableHead>Branch</TableHead>
<TableHead>Method</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{executions.map((exec) => (
<TableRow
key={exec.id}
className={`cursor-pointer hover:bg-muted/50 ${highlightId === String(exec.id) ? 'bg-muted' : ''}`}
onClick={() => loadExecutionDetail(exec.id)}
>
<TableCell>{statusIcon(exec.status)}</TableCell>
<TableCell className="font-medium">
{exec.ticket_number ? `#${exec.ticket_number}` : `ID ${exec.entity_id}`}
</TableCell>
<TableCell>
{exec.branch && <Badge variant="outline">{exec.branch}</Badge>}
</TableCell>
<TableCell>{methodBadge(exec.classification_method)}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{exec.duration_ms ? `${exec.duration_ms}ms` : '-'}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(exec.created_at).toLocaleString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* Pagination */}
<div className="flex items-center justify-between mt-4">
<span className="text-sm text-muted-foreground">
Page {page + 1} of {totalPages}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page === 0} onClick={() => setPage(p => p - 1)}>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages - 1} onClick={() => setPage(p => p + 1)}>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
{/* Execution Detail Dialog */}
<Dialog open={!!selectedExecution} onOpenChange={(open) => !open && setSelectedExecution(null)}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
Execution #{selectedExecution?.id}
{selectedExecution?.ticket_number && ` — Ticket #${selectedExecution.ticket_number}`}
</DialogTitle>
</DialogHeader>
{detailLoading ? (
<div className="flex justify-center py-8"><Loader2 className="w-6 h-6 animate-spin" /></div>
) : selectedExecution && (
<div className="space-y-4">
{/* Summary */}
<div className="grid grid-cols-4 gap-4">
<div>
<p className="text-xs text-muted-foreground">Status</p>
<div className="flex items-center gap-1 mt-1">
{statusIcon(selectedExecution.status)}
<span className="font-medium text-sm">{selectedExecution.status}</span>
</div>
</div>
<div>
<p className="text-xs text-muted-foreground">Method</p>
<div className="mt-1">{methodBadge(selectedExecution.classification_method)}</div>
</div>
<div>
<p className="text-xs text-muted-foreground">Branch</p>
<p className="font-medium text-sm mt-1">{selectedExecution.branch || '-'}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Duration</p>
<p className="font-medium text-sm mt-1">{selectedExecution.duration_ms}ms</p>
</div>
</div>
{selectedExecution.error_message && (
<div className="bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded p-3">
<p className="text-sm text-red-600 dark:text-red-400">{selectedExecution.error_message}</p>
</div>
)}
{/* Steps */}
<div>
<h4 className="font-medium mb-2">Processing Steps</h4>
<div className="space-y-2">
{selectedExecution.steps?.map((step, i) => (
<div key={step.id || i} className="border rounded-lg p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{statusIcon(step.status)}
<span className="font-medium text-sm">{step.step_name}</span>
{step.method && (
<Badge variant={step.method === 'robotic' ? 'default' : 'secondary'} className="text-xs">
{step.method}
</Badge>
)}
{step.confidence && (
<Badge variant="outline" className="text-xs">{step.confidence}</Badge>
)}
</div>
{step.duration_ms != null && (
<span className="text-xs text-muted-foreground">{step.duration_ms}ms</span>
)}
</div>
{step.output_data && (
<pre className="text-xs text-muted-foreground mt-2 bg-muted p-2 rounded overflow-x-auto">
{JSON.stringify(step.output_data, null, 2)}
</pre>
)}
{step.field_changes && (
<div className="mt-2 text-xs">
{Object.entries(step.field_changes).map(([field, change]) => (
<div key={field}>
<span className="font-medium">{field}:</span>{' '}
<span className="text-red-500">{String((change as any).before)}</span>
{' → '}
<span className="text-green-500">{String((change as any).after)}</span>
</div>
))}
</div>
)}
{step.error_message && (
<p className="text-xs text-red-500 mt-1">{step.error_message}</p>
)}
</div>
))}
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}

291
app/admin/workflow/page.tsx Normal file
View file

@ -0,0 +1,291 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import {
ArrowLeft,
Workflow,
Bot,
Cog,
ListFilter,
History,
FileText,
Settings,
CheckCircle2,
XCircle,
Clock,
Zap,
Brain,
Activity,
} from 'lucide-react';
interface ExecutionStats {
total: number;
completed: number;
failed: number;
skipped: number;
robotic: number;
ai: number;
hybrid: number;
}
export default function WorkflowDashboardPage() {
const [enabled, setEnabled] = useState(false);
const [stats, setStats] = useState<ExecutionStats>({ total: 0, completed: 0, failed: 0, skipped: 0, robotic: 0, ai: 0, hybrid: 0 });
const [recentExecutions, setRecentExecutions] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadDashboard();
}, []);
const loadDashboard = async () => {
setIsLoading(true);
try {
const [settingsRes, execRes] = await Promise.all([
fetch('/api/workflow/settings'),
fetch('/api/workflow/executions?limit=10'),
]);
if (settingsRes.ok) {
const settings = await settingsRes.json();
setEnabled(settings.workflow_engine_enabled?.value ?? false);
}
if (execRes.ok) {
const execData = await execRes.json();
setRecentExecutions(execData.data || []);
// Calculate stats from recent executions
const all = execData.data || [];
setStats({
total: execData.total || 0,
completed: all.filter((e: any) => e.status === 'completed').length,
failed: all.filter((e: any) => e.status === 'failed').length,
skipped: all.filter((e: any) => e.status === 'skipped').length,
robotic: all.filter((e: any) => e.classification_method === 'robotic').length,
ai: all.filter((e: any) => e.classification_method === 'ai').length,
hybrid: all.filter((e: any) => e.classification_method === 'hybrid').length,
});
}
} catch (error) {
console.error('Failed to load dashboard:', error);
} finally {
setIsLoading(false);
}
};
const toggleEngine = async () => {
try {
const newValue = !enabled;
await fetch('/api/workflow/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow_engine_enabled: newValue }),
});
setEnabled(newValue);
} catch (error) {
console.error('Failed to toggle engine:', error);
}
};
const navCards = [
{
title: 'Classification Rules',
description: 'Keyword-based rules for ticket classification',
href: '/admin/workflow/classification',
icon: Bot,
color: 'text-blue-500',
},
{
title: 'Filter Rules',
description: 'Exclusion/inclusion filters for ticket processing',
href: '/admin/workflow/rules',
icon: ListFilter,
color: 'text-orange-500',
},
{
title: 'AI Templates',
description: 'Prompt templates for AI-assisted classification',
href: '/admin/workflow/templates',
icon: Brain,
color: 'text-purple-500',
},
{
title: 'Execution History',
description: 'View past workflow executions and results',
href: '/admin/workflow/history',
icon: History,
color: 'text-green-500',
},
{
title: 'Settings',
description: 'AI providers, thresholds, and delays',
href: '/admin/workflow/settings',
icon: Settings,
color: 'text-gray-500',
},
];
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/sync">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<Workflow className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">Workflow Engine</h1>
<p className="text-sm text-muted-foreground">Automated ticket triage and classification</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
{enabled ? 'Engine Active' : 'Engine Disabled'}
</span>
<Switch checked={enabled} onCheckedChange={toggleEngine} />
<Badge variant={enabled ? 'default' : 'secondary'}>
{enabled ? 'ON' : 'OFF'}
</Badge>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Total Executions</p>
<p className="text-2xl font-bold">{stats.total}</p>
</div>
<Activity className="w-8 h-8 text-muted-foreground/50" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Completed</p>
<p className="text-2xl font-bold text-green-600">{stats.completed}</p>
</div>
<CheckCircle2 className="w-8 h-8 text-green-500/50" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Robotic</p>
<p className="text-2xl font-bold text-blue-600">{stats.robotic}</p>
</div>
<Zap className="w-8 h-8 text-blue-500/50" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">AI/Hybrid</p>
<p className="text-2xl font-bold text-purple-600">{stats.ai + stats.hybrid}</p>
</div>
<Brain className="w-8 h-8 text-purple-500/50" />
</div>
</CardContent>
</Card>
</div>
{/* Navigation Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{navCards.map((card) => (
<Link key={card.href} href={card.href}>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
<CardHeader>
<div className="flex items-center gap-3">
<card.icon className={`w-5 h-5 ${card.color}`} />
<CardTitle className="text-lg">{card.title}</CardTitle>
</div>
<CardDescription>{card.description}</CardDescription>
</CardHeader>
</Card>
</Link>
))}
</div>
{/* Recent Executions */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Recent Executions</CardTitle>
<CardDescription>Last 10 workflow runs</CardDescription>
</div>
<Link href="/admin/workflow/history">
<Button variant="outline" size="sm">View All</Button>
</Link>
</div>
</CardHeader>
<CardContent>
{recentExecutions.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No executions yet. Workflow engine will process incoming tickets when enabled.
</p>
) : (
<div className="space-y-2">
{recentExecutions.map((exec) => (
<Link key={exec.id} href={`/admin/workflow/history?id=${exec.id}`}>
<div className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors cursor-pointer">
<div className="flex items-center gap-3">
{exec.status === 'completed' && <CheckCircle2 className="w-4 h-4 text-green-500" />}
{exec.status === 'failed' && <XCircle className="w-4 h-4 text-red-500" />}
{exec.status === 'skipped' && <Clock className="w-4 h-4 text-gray-500" />}
{exec.status === 'running' && <Activity className="w-4 h-4 text-blue-500 animate-pulse" />}
<div>
<span className="font-medium text-sm">
{exec.ticket_number ? `Ticket #${exec.ticket_number}` : `Entity ${exec.entity_id}`}
</span>
<span className="text-xs text-muted-foreground ml-2">
{new Date(exec.created_at).toLocaleString()}
</span>
</div>
</div>
<div className="flex items-center gap-2">
{exec.branch && (
<Badge variant="outline" className="text-xs">{exec.branch}</Badge>
)}
<Badge variant={
exec.classification_method === 'robotic' ? 'default' :
exec.classification_method === 'ai' ? 'secondary' : 'outline'
} className="text-xs">
{exec.classification_method || 'n/a'}
</Badge>
{exec.duration_ms && (
<span className="text-xs text-muted-foreground">{exec.duration_ms}ms</span>
)}
</div>
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,365 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
ArrowLeft,
ListFilter,
Plus,
Pencil,
Trash2,
Loader2,
} from 'lucide-react';
import { toast } from 'sonner';
import { WorkflowRuleWithDetails, ConditionOperator } from '@/lib/types/workflow';
const CONDITION_FIELDS = [
'person_id', 'creator_resource_id', 'ticket_category', 'company_id',
'title', 'description', 'priority', 'ticket_type', 'status', 'queue_id',
];
const CONDITION_OPERATORS: { value: ConditionOperator; label: string }[] = [
{ value: 'equals', label: 'Equals' },
{ value: 'not_equals', label: 'Not Equals' },
{ value: 'in', label: 'In List' },
{ value: 'not_in', label: 'Not In List' },
{ value: 'contains', label: 'Contains' },
{ value: 'not_contains', label: 'Not Contains' },
{ value: 'is_null', label: 'Is Null' },
{ value: 'is_not_null', label: 'Is Not Null' },
];
export default function WorkflowRulesPage() {
const [rules, setRules] = useState<WorkflowRuleWithDetails[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [editingRule, setEditingRule] = useState<any | null>(null);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => { loadRules(); }, []);
const loadRules = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/workflow/rules');
if (res.ok) setRules(await res.json());
} catch { toast.error('Failed to load rules'); }
finally { setIsLoading(false); }
};
const handleCreate = () => {
setEditingRule({
name: '',
description: '',
trigger_event: 'ticket.created',
is_active: true,
sort_order: 0,
stop_processing: true,
conditions: [{ condition_group: 0, field: 'ticket_category', operator: 'in', value: [] }],
actions: [{ sort_order: 0, action_type: 'skip', config: { reason: '' } }],
});
};
const handleEdit = (rule: WorkflowRuleWithDetails) => {
setEditingRule({
...rule,
conditions: rule.conditions.map(c => ({ ...c, value: c.value })),
actions: rule.actions.map(a => ({ ...a })),
});
};
const handleSave = async () => {
if (!editingRule) return;
setIsSaving(true);
try {
const isUpdate = editingRule.id;
const url = isUpdate ? `/api/workflow/rules/${editingRule.id}` : '/api/workflow/rules';
const res = await fetch(url, {
method: isUpdate ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingRule),
});
if (res.ok) {
toast.success(isUpdate ? 'Rule updated' : 'Rule created');
setEditingRule(null);
loadRules();
} else {
toast.error('Failed to save rule');
}
} catch { toast.error('Failed to save rule'); }
finally { setIsSaving(false); }
};
const handleDelete = async (id: number) => {
if (!confirm('Delete this workflow rule and all its conditions?')) return;
try {
const res = await fetch(`/api/workflow/rules/${id}`, { method: 'DELETE' });
if (res.ok) { toast.success('Rule deleted'); loadRules(); }
} catch { toast.error('Failed to delete rule'); }
};
const handleToggle = async (rule: WorkflowRuleWithDetails) => {
try {
await fetch(`/api/workflow/rules/${rule.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !rule.is_active }),
});
loadRules();
} catch { toast.error('Failed to toggle rule'); }
};
const summarizeConditions = (rule: WorkflowRuleWithDetails): string => {
return rule.conditions.map(c => `${c.field} ${c.operator} ${JSON.stringify(c.value)}`).join(' AND ');
};
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/workflow">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<ListFilter className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">Filter Rules</h1>
<p className="text-sm text-muted-foreground">Exclusion/inclusion filters for ticket processing</p>
</div>
</div>
<Button onClick={handleCreate} size="sm">
<Plus className="w-4 h-4 mr-2" />
New Rule
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Active Filter Rules</CardTitle>
<CardDescription>Rules are evaluated in sort order. Matching a &quot;skip&quot; action stops processing.</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center py-8"><Loader2 className="w-6 h-6 animate-spin" /></div>
) : rules.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No filter rules configured.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Order</TableHead>
<TableHead>Name</TableHead>
<TableHead>Trigger</TableHead>
<TableHead>Conditions</TableHead>
<TableHead>Action</TableHead>
<TableHead className="w-20">Active</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rules.map((rule) => (
<TableRow key={rule.id}>
<TableCell className="font-mono">{rule.sort_order}</TableCell>
<TableCell className="font-medium">{rule.name}</TableCell>
<TableCell><Badge variant="outline">{rule.trigger_event}</Badge></TableCell>
<TableCell>
<div className="text-xs text-muted-foreground max-w-xs truncate" title={summarizeConditions(rule)}>
{summarizeConditions(rule)}
</div>
</TableCell>
<TableCell>
{rule.actions.map(a => (
<Badge key={a.id} variant="secondary" className="text-xs">{a.action_type}</Badge>
))}
</TableCell>
<TableCell>
<Switch checked={rule.is_active} onCheckedChange={() => handleToggle(rule)} />
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => handleEdit(rule)}>
<Pencil className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(rule.id)}>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Edit Dialog */}
<Dialog open={!!editingRule} onOpenChange={(open) => !open && setEditingRule(null)}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingRule?.id ? 'Edit' : 'New'} Filter Rule</DialogTitle>
<DialogDescription>Configure conditions that determine which tickets to process or skip</DialogDescription>
</DialogHeader>
{editingRule && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Name</Label>
<Input value={editingRule.name} onChange={(e) => setEditingRule({ ...editingRule, name: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Sort Order</Label>
<Input type="number" value={editingRule.sort_order} onChange={(e) => setEditingRule({ ...editingRule, sort_order: parseInt(e.target.value) })} />
</div>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input value={editingRule.description || ''} onChange={(e) => setEditingRule({ ...editingRule, description: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Trigger Event</Label>
<Select value={editingRule.trigger_event} onValueChange={(v) => setEditingRule({ ...editingRule, trigger_event: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="ticket.created">Ticket Created</SelectItem>
<SelectItem value="ticket.updated">Ticket Updated</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2 pb-2">
<Switch checked={editingRule.stop_processing} onCheckedChange={(v) => setEditingRule({ ...editingRule, stop_processing: v })} />
<Label>Stop processing on match</Label>
</div>
</div>
{/* Conditions */}
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<h4 className="font-medium">Conditions</h4>
<Button variant="outline" size="sm" onClick={() => {
setEditingRule({
...editingRule,
conditions: [...editingRule.conditions, { condition_group: 0, field: 'ticket_category', operator: 'equals', value: '' }],
});
}}>
<Plus className="w-3 h-3 mr-1" /> Add
</Button>
</div>
{editingRule.conditions.map((cond: any, i: number) => (
<div key={i} className="grid grid-cols-4 gap-2 items-end">
<Select value={cond.field} onValueChange={(v) => {
const updated = [...editingRule.conditions];
updated[i] = { ...updated[i], field: v };
setEditingRule({ ...editingRule, conditions: updated });
}}>
<SelectTrigger className="text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{CONDITION_FIELDS.map(f => <SelectItem key={f} value={f}>{f}</SelectItem>)}
</SelectContent>
</Select>
<Select value={cond.operator} onValueChange={(v) => {
const updated = [...editingRule.conditions];
updated[i] = { ...updated[i], operator: v };
setEditingRule({ ...editingRule, conditions: updated });
}}>
<SelectTrigger className="text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{CONDITION_OPERATORS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<Input
className="text-xs"
value={typeof cond.value === 'string' ? cond.value : JSON.stringify(cond.value)}
onChange={(e) => {
const updated = [...editingRule.conditions];
let val: any = e.target.value;
try { val = JSON.parse(val); } catch {}
updated[i] = { ...updated[i], value: val };
setEditingRule({ ...editingRule, conditions: updated });
}}
placeholder="value or [1,2,3]"
/>
<Button variant="ghost" size="sm" onClick={() => {
setEditingRule({
...editingRule,
conditions: editingRule.conditions.filter((_: any, j: number) => j !== i),
});
}}>
<Trash2 className="w-3 h-3 text-red-500" />
</Button>
</div>
))}
</div>
{/* Action */}
<div className="border rounded-lg p-4 space-y-3">
<h4 className="font-medium">Action</h4>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Action Type</Label>
<Select value={editingRule.actions[0]?.action_type || 'skip'} onValueChange={(v) => {
const actions = [{ ...editingRule.actions[0], action_type: v }];
setEditingRule({ ...editingRule, actions });
}}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="skip">Skip (exclude from triage)</SelectItem>
<SelectItem value="classify">Classify</SelectItem>
<SelectItem value="set_field">Set Field</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Reason / Config</Label>
<Input
value={editingRule.actions[0]?.config?.reason || ''}
onChange={(e) => {
const actions = [{ ...editingRule.actions[0], config: { reason: e.target.value } }];
setEditingRule({ ...editingRule, actions });
}}
placeholder="Skip reason"
/>
</div>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setEditingRule(null)}>Cancel</Button>
<Button onClick={handleSave} disabled={isSaving || !editingRule.name}>
{isSaving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{editingRule.id ? 'Update' : 'Create'} Rule
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,274 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ArrowLeft, Settings, Loader2, Save } from 'lucide-react';
import { toast } from 'sonner';
interface SettingEntry {
value: any;
description: string;
}
export default function WorkflowSettingsPage() {
const [settings, setSettings] = useState<Record<string, SettingEntry>>({});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [modified, setModified] = useState<Record<string, any>>({});
useEffect(() => { loadSettings(); }, []);
const loadSettings = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/workflow/settings');
if (res.ok) {
setSettings(await res.json());
setModified({});
}
} catch { toast.error('Failed to load settings'); }
finally { setIsLoading(false); }
};
const getValue = (key: string): any => {
if (key in modified) return modified[key];
return settings[key]?.value;
};
const setValue = (key: string, value: any) => {
setModified({ ...modified, [key]: value });
};
const handleSave = async () => {
if (Object.keys(modified).length === 0) return;
setIsSaving(true);
try {
const res = await fetch('/api/workflow/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(modified),
});
if (res.ok) {
toast.success('Settings saved');
setSettings(await res.json());
setModified({});
}
} catch { toast.error('Failed to save settings'); }
finally { setIsSaving(false); }
};
if (isLoading) {
return (
<div className="container mx-auto p-6 flex justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
);
}
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/workflow">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<Settings className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">Workflow Settings</h1>
<p className="text-sm text-muted-foreground">Configure AI providers, thresholds, and behavior</p>
</div>
</div>
{Object.keys(modified).length > 0 && (
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Save className="w-4 h-4 mr-2" />}
Save Changes
</Button>
)}
</div>
{/* Engine Control */}
<Card>
<CardHeader>
<CardTitle>Engine Control</CardTitle>
<CardDescription>Master enable/disable for the workflow engine</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<Label>Workflow Engine</Label>
<p className="text-sm text-muted-foreground">When enabled, new tickets are automatically classified</p>
</div>
<Switch
checked={getValue('workflow_engine_enabled') ?? false}
onCheckedChange={(v) => setValue('workflow_engine_enabled', v)}
/>
</div>
</CardContent>
</Card>
{/* AI Provider Config */}
<Card>
<CardHeader>
<CardTitle>AI Provider Configuration</CardTitle>
<CardDescription>Configure API keys and models for AI-assisted classification</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>Default AI Provider</Label>
<Select value={getValue('default_ai_provider') || 'openai'} onValueChange={(v) => setValue('default_ai_provider', v)}>
<SelectTrigger className="w-60"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="anthropic">Anthropic</SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
<div className="grid grid-cols-2 gap-6">
<div className="space-y-4">
<h4 className="font-medium">OpenAI</h4>
<div className="space-y-2">
<Label>API Key</Label>
<Input
type="password"
value={getValue('openai_api_key') || ''}
onChange={(e) => setValue('openai_api_key', e.target.value)}
placeholder="sk-..."
/>
</div>
<div className="space-y-2">
<Label>Model</Label>
<Input
value={getValue('openai_model') || 'gpt-4o'}
onChange={(e) => setValue('openai_model', e.target.value)}
/>
</div>
</div>
<div className="space-y-4">
<h4 className="font-medium">Anthropic</h4>
<div className="space-y-2">
<Label>API Key</Label>
<Input
type="password"
value={getValue('anthropic_api_key') || ''}
onChange={(e) => setValue('anthropic_api_key', e.target.value)}
placeholder="sk-ant-..."
/>
</div>
<div className="space-y-2">
<Label>Model</Label>
<Input
value={getValue('anthropic_model') || 'claude-sonnet-4-20250514'}
onChange={(e) => setValue('anthropic_model', e.target.value)}
/>
</div>
</div>
</div>
</CardContent>
</Card>
{/* AI Feature Toggles */}
<Card>
<CardHeader>
<CardTitle>AI Feature Toggles</CardTitle>
<CardDescription>Control when AI is used during triage</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{[
{ key: 'ai_for_title_cleanup', label: 'Title Cleanup', desc: 'Use AI to clean up messy ticket titles (email subjects, long titles)' },
{ key: 'ai_for_description_rewrite', label: 'Description Rewrite', desc: 'Use AI to restructure unstructured ticket descriptions' },
{ key: 'ai_for_ambiguous_classification', label: 'Ambiguous Classification', desc: 'Use AI when robotic classifier has no confident match' },
{ key: 'ai_for_troubleshooting', label: 'Troubleshooting Steps', desc: 'Generate AI troubleshooting steps for incident tickets' },
].map(({ key, label, desc }) => (
<div key={key} className="flex items-center justify-between">
<div>
<Label>{label}</Label>
<p className="text-sm text-muted-foreground">{desc}</p>
</div>
<Switch
checked={getValue(key) ?? true}
onCheckedChange={(v) => setValue(key, v)}
/>
</div>
))}
</CardContent>
</Card>
{/* Processing Config */}
<Card>
<CardHeader>
<CardTitle>Processing Configuration</CardTitle>
<CardDescription>Thresholds, delays, and retry settings</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<Label>Confidence Threshold</Label>
<p className="text-xs text-muted-foreground">Minimum confidence to skip AI classification</p>
<Select
value={getValue('classification_confidence_threshold') || 'medium'}
onValueChange={(v) => setValue('classification_confidence_threshold', v)}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="high">High (AI for medium + low)</SelectItem>
<SelectItem value="medium">Medium (AI only for low)</SelectItem>
<SelectItem value="low">Low (AI rarely used)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Autotask Update Delay (ms)</Label>
<p className="text-xs text-muted-foreground">Delay before writing back to Autotask to avoid WF rule conflicts</p>
<Input
type="number"
value={getValue('autotask_update_delay_ms') ?? 30000}
onChange={(e) => setValue('autotask_update_delay_ms', parseInt(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label>Max AI Retries</Label>
<p className="text-xs text-muted-foreground">Max retry attempts when AI validation fails</p>
<Input
type="number"
value={getValue('max_ai_retries') ?? 2}
onChange={(e) => setValue('max_ai_retries', parseInt(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label>Log Retention (days)</Label>
<p className="text-xs text-muted-foreground">Days to retain execution logs</p>
<Input
type="number"
value={getValue('log_retention_days') ?? 90}
onChange={(e) => setValue('log_retention_days', parseInt(e.target.value))}
/>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,261 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ArrowLeft, Brain, Plus, Pencil, Trash2, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { AiPromptTemplate, PromptPurpose } from '@/lib/types/workflow';
const PURPOSES: { value: PromptPurpose; label: string }[] = [
{ value: 'title_cleanup', label: 'Title Cleanup' },
{ value: 'description_rewrite', label: 'Description Rewrite' },
{ value: 'ambiguous_classification', label: 'Ambiguous Classification' },
{ value: 'troubleshooting_steps', label: 'Troubleshooting Steps' },
{ value: 'noc_format', label: 'NOC Format' },
{ value: 'soc_analysis', label: 'SOC Analysis' },
];
export default function TemplatesPage() {
const [templates, setTemplates] = useState<AiPromptTemplate[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [editing, setEditing] = useState<Partial<AiPromptTemplate> | null>(null);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => { loadTemplates(); }, []);
const loadTemplates = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/workflow/templates');
if (res.ok) setTemplates(await res.json());
} catch { toast.error('Failed to load templates'); }
finally { setIsLoading(false); }
};
const handleCreate = () => {
setEditing({
name: '',
purpose: 'title_cleanup',
system_prompt: '',
user_prompt_template: '',
provider: 'openai',
model: 'gpt-4o',
temperature: 0.3,
max_tokens: 4000,
is_active: true,
});
};
const handleSave = async () => {
if (!editing) return;
setIsSaving(true);
try {
const isUpdate = editing.id;
const url = isUpdate ? `/api/workflow/templates/${editing.id}` : '/api/workflow/templates';
const res = await fetch(url, {
method: isUpdate ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editing),
});
if (res.ok) {
toast.success(isUpdate ? 'Template updated' : 'Template created');
setEditing(null);
loadTemplates();
}
} catch { toast.error('Failed to save template'); }
finally { setIsSaving(false); }
};
const handleDelete = async (id: number) => {
if (!confirm('Delete this template?')) return;
try {
const res = await fetch(`/api/workflow/templates/${id}`, { method: 'DELETE' });
if (res.ok) { toast.success('Template deleted'); loadTemplates(); }
} catch { toast.error('Failed to delete'); }
};
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/workflow">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<Brain className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">AI Prompt Templates</h1>
<p className="text-sm text-muted-foreground">Configure prompts for AI-assisted triage</p>
</div>
</div>
<Button onClick={handleCreate} size="sm">
<Plus className="w-4 h-4 mr-2" />
New Template
</Button>
</div>
<Card>
<CardContent className="pt-6">
{isLoading ? (
<div className="flex justify-center py-8"><Loader2 className="w-6 h-6 animate-spin" /></div>
) : templates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No templates configured. Default prompts will be used.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Purpose</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Model</TableHead>
<TableHead className="w-20">Active</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templates.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell><Badge variant="outline">{t.purpose}</Badge></TableCell>
<TableCell>{t.provider}</TableCell>
<TableCell className="text-sm text-muted-foreground">{t.model}</TableCell>
<TableCell>
<Badge variant={t.is_active ? 'default' : 'secondary'}>
{t.is_active ? 'Yes' : 'No'}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => setEditing({ ...t })}>
<Pencil className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(t.id)}>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Edit Dialog */}
<Dialog open={!!editing} onOpenChange={(open) => !open && setEditing(null)}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing?.id ? 'Edit' : 'New'} AI Template</DialogTitle>
<DialogDescription>
Templates support {'{{field}}'} interpolation. Available variables: title, description, ticket_category, failed_fields, issueTypes, subIssueTypes, priorities.
</DialogDescription>
</DialogHeader>
{editing && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Name</Label>
<Input value={editing.name || ''} onChange={(e) => setEditing({ ...editing, name: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Purpose</Label>
<Select value={editing.purpose} onValueChange={(v) => setEditing({ ...editing, purpose: v as PromptPurpose })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{PURPOSES.map(p => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Provider</Label>
<Select value={editing.provider || 'openai'} onValueChange={(v) => setEditing({ ...editing, provider: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="anthropic">Anthropic</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Model</Label>
<Input value={editing.model || ''} onChange={(e) => setEditing({ ...editing, model: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Temperature</Label>
<Input type="number" step="0.1" min="0" max="2" value={editing.temperature ?? 0.3} onChange={(e) => setEditing({ ...editing, temperature: parseFloat(e.target.value) })} />
</div>
</div>
<div className="space-y-2">
<Label>System Prompt</Label>
<Textarea
value={editing.system_prompt || ''}
onChange={(e) => setEditing({ ...editing, system_prompt: e.target.value })}
rows={6}
className="font-mono text-sm"
/>
</div>
<div className="space-y-2">
<Label>User Prompt Template</Label>
<Textarea
value={editing.user_prompt_template || ''}
onChange={(e) => setEditing({ ...editing, user_prompt_template: e.target.value })}
rows={8}
className="font-mono text-sm"
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={editing.is_active ?? true}
onCheckedChange={(v) => setEditing({ ...editing, is_active: v })}
/>
<Label>Active</Label>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setEditing(null)}>Cancel</Button>
<Button onClick={handleSave} disabled={isSaving || !editing.name}>
{isSaving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{editing.id ? 'Update' : 'Create'}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET() {
try {
const [statuses, resources, companies, issueTypes, subIssueTypes, queues, configItems, priorities, ticketCategories] = await Promise.all([
postgresClient.query(`SELECT value, label FROM statuses WHERE is_deleted = false ORDER BY value`),
postgresClient.query(`SELECT id, first_name, last_name FROM resources WHERE is_deleted = false ORDER BY last_name, first_name`),
postgresClient.query(`SELECT id, company_name FROM companies WHERE is_deleted = false ORDER BY company_name`),
postgresClient.query(`SELECT value, label FROM issue_types WHERE is_deleted = false ORDER BY label`),
postgresClient.query(`SELECT value, label, parent_value FROM sub_issue_types WHERE is_deleted = false ORDER BY label`),
postgresClient.query(`SELECT value, label FROM queues WHERE is_active = true ORDER BY label`),
postgresClient.query(`SELECT id, reference_title FROM configuration_items WHERE is_deleted = false AND reference_title IS NOT NULL ORDER BY reference_title`),
postgresClient.query(`SELECT value, label FROM priorities WHERE is_active = true ORDER BY sort_order, value`),
postgresClient.query(`SELECT value, label FROM ticket_categories WHERE is_active = true ORDER BY sort_order, value`),
]);
return NextResponse.json({
statuses: statuses.rows,
resources: resources.rows.map((r: any) => ({
id: r.id,
name: [r.first_name, r.last_name].filter(Boolean).join(' ').trim() || `Resource ${r.id}`,
})),
companies: companies.rows.map((c: any) => ({ id: c.id, name: c.company_name })),
issueTypes: issueTypes.rows,
subIssueTypes: subIssueTypes.rows,
queues: queues.rows,
configItems: configItems.rows.map((r: any) => ({ id: r.id, name: r.reference_title })),
priorities: priorities.rows,
ticketCategories: ticketCategories.rows,
});
} catch (error) {
console.error('Failed to fetch lookups:', error);
return NextResponse.json({ error: 'Failed to fetch lookups' }, { status: 500 });
}
}

View file

@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const limit = parseInt(searchParams.get('limit') || '100');
const offset = parseInt(searchParams.get('offset') || '0');
const search = searchParams.get('search') || '';
const ticketId = searchParams.get('ticket_id');
const creatorId = searchParams.get('creator_resource_id');
const sortBy = searchParams.get('sort_by') || 'create_date_time';
const sortOrder = searchParams.get('sort_order')?.toLowerCase() === 'asc' ? 'ASC' : 'DESC';
const conditions: string[] = ['tn.is_deleted = false'];
const params: any[] = [];
let p = 1;
if (search) {
conditions.push(`(tn.title ILIKE $${p} OR tn.description ILIKE $${p})`);
params.push(`%${search}%`);
p++;
}
if (ticketId) {
conditions.push(`tn.ticket_id = $${p}`);
params.push(ticketId);
p++;
}
if (creatorId) {
conditions.push(`tn.creator_resource_id = $${p}`);
params.push(creatorId);
p++;
}
const validSort = ['create_date_time', 'last_activity_date', 'ticket_id', 'creator_resource_id', 'note_type'];
const safeSort = validSort.includes(sortBy) ? sortBy : 'create_date_time';
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const query = `
SELECT
tn.id,
tn.ticket_id,
t.ticket_number,
tn.title,
tn.description,
tn.note_type,
tn.publish,
tn.creator_resource_id,
r.first_name || ' ' || r.last_name AS creator_name,
tn.creator_type,
tn.create_date_time,
tn.last_activity_date,
tn.synced_at
FROM ticket_notes tn
LEFT JOIN tickets t ON tn.ticket_id = t.id
LEFT JOIN resources r ON tn.creator_resource_id = r.id
${where}
ORDER BY tn.${safeSort} ${sortOrder}
LIMIT $${p} OFFSET $${p + 1}
`;
params.push(limit, offset);
const countQuery = `SELECT COUNT(*) AS total FROM ticket_notes tn ${where}`;
const [result, countResult] = await Promise.all([
postgresClient.query(query, params),
postgresClient.query(countQuery, params.slice(0, -2)),
]);
return NextResponse.json({
ticketNotes: result.rows,
pagination: {
total: parseInt(countResult.rows[0].total),
limit,
offset,
hasMore: offset + limit < parseInt(countResult.rows[0].total),
},
});
} catch (error) {
console.error('Error fetching ticket notes:', error);
return NextResponse.json({ error: 'Failed to fetch ticket notes' }, { status: 500 });
}
}

View file

@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import { DattoRMMSyncService } from '@/lib/services/datto-rmm-sync-service';
let syncServiceInstance: DattoRMMSyncService | null = null;
function getSyncService(): DattoRMMSyncService {
if (!syncServiceInstance) {
syncServiceInstance = new DattoRMMSyncService();
}
return syncServiceInstance;
}
export async function POST(request: NextRequest) {
try {
const syncService = getSyncService();
if (syncService.isSyncInProgress()) {
return NextResponse.json(
{ error: 'A Datto RMM sync is already in progress' },
{ status: 409 }
);
}
const body = await request.json().catch(() => ({}));
const syncType = body.syncType === 'full' ? 'full' : 'incremental';
const resultPromise = syncType === 'full'
? syncService.fullSync('manual')
: syncService.incrementalSync('manual');
resultPromise.catch((err) => {
console.error('[DATTO-RMM-SYNC-API] Background sync failed:', err);
});
return NextResponse.json({
message: `Datto RMM ${syncType} sync started`,
syncType,
});
} catch (error) {
console.error('[DATTO-RMM-SYNC-API] Error:', error);
return NextResponse.json(
{ error: 'Failed to start Datto RMM sync' },
{ status: 500 }
);
}
}
export async function GET() {
try {
const syncService = getSyncService();
return NextResponse.json({
isSyncing: syncService.isSyncInProgress(),
});
} catch (error) {
return NextResponse.json(
{ error: 'Failed to get sync status' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,114 @@
import { NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET() {
const results: Record<string, any> = {};
// ── Veeam VSPC ──────────────────────────────────────────────────────────────
try {
const [orgs, agentJobs, backupJobs, workloads] = await Promise.all([
postgresClient.query('SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM veeam_organizations'),
postgresClient.query(`SELECT COUNT(*) as total,
COUNT(*) FILTER (WHERE status='Success') as success,
COUNT(*) FILTER (WHERE status='Running') as running,
COUNT(*) FILTER (WHERE status='Failed') as failed,
COUNT(*) FILTER (WHERE status='Warning') as warning
FROM veeam_backup_agent_jobs WHERE is_enabled = true`),
postgresClient.query(`SELECT COUNT(*) as total,
COUNT(*) FILTER (WHERE status='Success') as success,
COUNT(*) FILTER (WHERE status='Failed') as failed,
COUNT(*) FILTER (WHERE status='Warning') as warning
FROM veeam_backup_jobs WHERE is_enabled = true`),
postgresClient.query('SELECT COUNT(*) as count FROM veeam_protected_workloads'),
]);
results.veeam = {
configured: !!(process.env.VEEAM_VSPC_URL && process.env.VEEAM_VSPC_API_KEY),
organizations: parseInt(orgs.rows[0].count),
lastSync: orgs.rows[0].last_sync,
agentJobs: {
total: parseInt(agentJobs.rows[0].total),
success: parseInt(agentJobs.rows[0].success),
running: parseInt(agentJobs.rows[0].running),
failed: parseInt(agentJobs.rows[0].failed),
warning: parseInt(agentJobs.rows[0].warning),
},
backupJobs: {
total: parseInt(backupJobs.rows[0].total),
success: parseInt(backupJobs.rows[0].success),
failed: parseInt(backupJobs.rows[0].failed),
warning: parseInt(backupJobs.rows[0].warning),
},
protectedWorkloads: parseInt(workloads.rows[0].count),
};
} catch (e) {
results.veeam = { configured: false, error: String(e) };
}
// ── Datto RMM ───────────────────────────────────────────────────────────────
try {
const [sites, devices, openAlerts, lastSync] = await Promise.all([
postgresClient.query(`SELECT COUNT(*) as total,
COALESCE(SUM(number_of_devices),0) as total_devices,
COALESCE(SUM(number_of_online_devices),0) as online_devices,
COALESCE(SUM(number_of_offline_devices),0) as offline_devices
FROM datto_rmm_sites`),
postgresClient.query(`SELECT COUNT(*) as total,
COUNT(*) FILTER (WHERE online = true) as online,
COUNT(*) FILTER (WHERE online = false AND deleted = false) as offline
FROM datto_rmm_devices WHERE deleted = false`),
postgresClient.query(`SELECT COUNT(*) as total,
COUNT(*) FILTER (WHERE priority = 'Critical') as critical,
COUNT(*) FILTER (WHERE priority = 'High') as high,
COUNT(*) FILTER (WHERE priority = 'Moderate') as moderate,
COUNT(*) FILTER (WHERE priority = 'Low') as low,
COUNT(*) FILTER (WHERE ticket_number IS NOT NULL) as with_ticket
FROM datto_rmm_alerts WHERE resolved = false`),
postgresClient.query(`SELECT MAX(synced_at) as last_sync FROM datto_rmm_sites`),
]);
results.dattoRmm = {
configured: !!(process.env.DATTO_RMM_API_KEY && process.env.DATTO_RMM_API_SECRET),
apiUrl: process.env.DATTO_RMM_API_URL || 'https://concord-api.centrastage.net',
lastSync: lastSync.rows[0].last_sync,
sites: parseInt(sites.rows[0].total),
devices: {
total: parseInt(devices.rows[0].total),
online: parseInt(devices.rows[0].online),
offline: parseInt(devices.rows[0].offline),
},
openAlerts: {
total: parseInt(openAlerts.rows[0].total),
critical: parseInt(openAlerts.rows[0].critical),
high: parseInt(openAlerts.rows[0].high),
moderate: parseInt(openAlerts.rows[0].moderate),
low: parseInt(openAlerts.rows[0].low),
withTicket: parseInt(openAlerts.rows[0].with_ticket),
},
};
} catch (e) {
results.dattoRmm = { configured: !!(process.env.DATTO_RMM_API_KEY && process.env.DATTO_RMM_API_SECRET), error: String(e) };
}
// ── Auvik ───────────────────────────────────────────────────────────────────
try {
results.auvik = {
configured: !!(process.env.AUVIK_API_KEY && process.env.AUVIK_API_USER),
apiUrl: process.env.AUVIK_API_URL || 'https://auvikapi.us5.my.auvik.com',
lastSync: null,
};
} catch (e) {
results.auvik = { configured: false, error: String(e) };
}
// ── Addigy ──────────────────────────────────────────────────────────────────
try {
results.addigy = {
configured: !!(process.env.ADDIGY_API_TOKEN),
apiUrl: process.env.ADDIGY_API_URL || 'https://api.addigy.com/api/v2',
lastSync: null,
};
} catch (e) {
results.addigy = { configured: false, error: String(e) };
}
return NextResponse.json(results);
}

View file

@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET() {
try {
const [agents, summary] = await Promise.all([
postgresClient.query(`
SELECT a.instance_uid, a.name, a.agent_platform, a.status, a.management_agent_status,
a.operation_mode, a.platform, a.version, a.version_status, a.management_mode,
a.total_jobs_count, a.running_jobs_count, a.success_jobs_count,
a.activation_time, a.synced_at,
o.name as organization_name
FROM veeam_backup_agents a
LEFT JOIN veeam_organizations o ON o.instance_uid = a.organization_uid
ORDER BY a.name ASC
`),
postgresClient.query(`
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE status = 'Active') as active,
COUNT(*) FILTER (WHERE status != 'Active') as inactive,
COUNT(*) FILTER (WHERE management_agent_status = 'Inaccessible') as inaccessible,
COUNT(*) FILTER (WHERE version_status = 'Outdated') as outdated,
COUNT(*) FILTER (WHERE agent_platform = 'Windows') as windows,
COUNT(*) FILTER (WHERE agent_platform = 'Linux') as linux,
COUNT(*) FILTER (WHERE agent_platform = 'Mac') as mac,
MAX(synced_at) as last_sync
FROM veeam_backup_agents
`),
]);
const s = summary.rows[0];
return NextResponse.json({
agents: agents.rows,
summary: {
total: parseInt(s.total),
active: parseInt(s.active),
inactive: parseInt(s.inactive),
inaccessible: parseInt(s.inaccessible),
outdated: parseInt(s.outdated),
windows: parseInt(s.windows),
linux: parseInt(s.linux),
mac: parseInt(s.mac),
lastSync: s.last_sync,
},
});
} catch (error) {
console.error('[VEEAM-AGENTS-API]', error);
return NextResponse.json({ error: 'Failed to fetch agents' }, { status: 500 });
}
}

View file

@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET() {
try {
const [alarms, summary] = await Promise.all([
postgresClient.query(`
SELECT a.instance_uid, a.object_type, a.object_name, a.object_computer_name,
a.last_activation_status, a.last_activation_time, a.last_activation_message,
a.last_activation_remark, a.repeat_count, a.area, a.resolved, a.synced_at,
o.name as organization_name
FROM veeam_alarms a
LEFT JOIN veeam_organizations o ON o.instance_uid = a.organization_uid
ORDER BY a.last_activation_time DESC NULLS LAST
LIMIT 500
`),
postgresClient.query(`
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE resolved = false) as active,
COUNT(*) FILTER (WHERE resolved = true) as resolved,
COUNT(*) FILTER (WHERE last_activation_status = 'Resolved') as status_resolved,
COUNT(*) FILTER (WHERE last_activation_status = 'Active') as status_active,
COUNT(*) FILTER (WHERE last_activation_status = 'Warning') as status_warning,
MAX(synced_at) as last_sync
FROM veeam_alarms
`),
]);
const s = summary.rows[0];
return NextResponse.json({
alarms: alarms.rows,
summary: {
total: parseInt(s.total),
active: parseInt(s.active),
resolved: parseInt(s.resolved),
statusResolved: parseInt(s.status_resolved),
statusActive: parseInt(s.status_active),
statusWarning: parseInt(s.status_warning),
lastSync: s.last_sync,
},
});
} catch (error) {
console.error('[VEEAM-ALARMS-API]', error);
return NextResponse.json({ error: 'Failed to fetch alarms' }, { status: 500 });
}
}

View file

@ -1,11 +1,15 @@
/**
* Autotask Webhook Receiver Endpoint
* Receives and processes real-time webhook events from Autotask
*
* Actual Autotask payload format:
* { Action, Guid, EntityType (singular), Id, Fields, EventTime, SequenceNumber, PersonId }
* Signature header: x-hook-signature: sha1=<base64>
*/
import { NextRequest, NextResponse } from 'next/server';
import { webhookService } from '@/lib/services/webhook-service';
import { AutotaskWebhookPayload } from '@/lib/types/webhook';
import { AutotaskRawWebhookPayload, normalizeWebhookPayload } from '@/lib/types/webhook';
/**
* POST /api/webhooks/autotask
@ -13,28 +17,44 @@ import { AutotaskWebhookPayload } from '@/lib/types/webhook';
*/
export async function POST(request: NextRequest) {
try {
// Extract source IP and user agent for logging
const sourceIp = request.headers.get('x-forwarded-for')?.split(',')[0].trim()
// Extract source IP — Autotask sends from 8.34.161.x via Cloudflare/Pangolin
const sourceIp = request.headers.get('cf-connecting-ip')
|| request.headers.get('x-forwarded-for')?.split(',')[0].trim()
|| request.headers.get('x-real-ip')
|| 'unknown';
const userAgent = request.headers.get('user-agent') || 'unknown';
// Parse webhook payload
const payload: AutotaskWebhookPayload = await request.json();
// Read raw body for signature verification
const rawBody = await request.text();
console.log(`[WEBHOOK API] Received ${payload.eventType} event for ${payload.entityType} #${payload.entityId} from IP: ${sourceIp}`);
// Verify webhook signature (x-hook-signature: sha1=<base64>)
const signatureHeader = request.headers.get('x-hook-signature');
if (!webhookService.verifySignature(rawBody, signatureHeader)) {
console.warn(`[WEBHOOK API] Invalid signature from IP: ${sourceIp}`);
return NextResponse.json(
{ error: 'Invalid webhook signature' },
{ status: 401 }
);
}
// Parse raw Autotask payload
const rawPayload: AutotaskRawWebhookPayload = JSON.parse(rawBody);
// Validate required fields
if (!payload.eventId || !payload.eventType || !payload.entityType || !payload.entityId) {
if (!rawPayload.Guid || !rawPayload.Action || !rawPayload.EntityType || !rawPayload.Id) {
console.warn(`[WEBHOOK API] Invalid payload from IP: ${sourceIp}`, rawBody.substring(0, 200));
return NextResponse.json(
{ error: 'Invalid webhook payload: missing required fields' },
{ error: 'Invalid webhook payload: missing required fields (Guid, Action, EntityType, Id)' },
{ status: 400 }
);
}
// Process the webhook asynchronously
// Note: We return 200 immediately to Autotask, then process in background
// This prevents timeouts for slow processing
// Normalize to our internal format
const payload = normalizeWebhookPayload(rawPayload);
console.log(`[WEBHOOK API] Received ${rawPayload.Action} event for ${rawPayload.EntityType} #${rawPayload.Id} from IP: ${sourceIp}`);
// Process the webhook — return 200 quickly to avoid Autotask timeouts
const result = await webhookService.processWebhook(payload, sourceIp, userAgent);
if (result.success) {
@ -45,8 +65,7 @@ export async function POST(request: NextRequest) {
processingTime: result.processingTime,
});
} else {
// Even if processing failed, we return 200 to Autotask
// The failure is logged in webhook_logs table
// Return 200 even on processing failure to prevent Autotask retries/deactivation
console.error(`[WEBHOOK API] Processing failed: ${result.error}`);
return NextResponse.json({
success: false,
@ -59,10 +78,10 @@ export async function POST(request: NextRequest) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('[WEBHOOK API] Error processing webhook:', errorMessage);
// Return 500 for unexpected errors
// Return 200 to prevent Autotask from deactivating the webhook on errors
return NextResponse.json(
{ error: 'Internal server error', details: errorMessage },
{ status: 500 }
{ status: 200 }
);
}
}

View file

@ -0,0 +1,93 @@
/**
* Webhook Registration API
* POST /api/webhooks/register Register all webhooks with Autotask
* POST /api/webhooks/register?entity=Tickets Register a single entity
*/
import { NextRequest, NextResponse } from 'next/server';
import { AutotaskWebhookManager } from '@/lib/services/autotask-webhook-manager';
import { WebhookEntityType, WEBHOOK_SUPPORTED_ENTITIES } from '@/lib/types/webhook';
export async function POST(request: NextRequest) {
try {
const manager = new AutotaskWebhookManager();
const { searchParams } = new URL(request.url);
const entityParam = searchParams.get('entity');
if (entityParam) {
// Register a single entity type
const entityType = entityParam as WebhookEntityType;
if (!WEBHOOK_SUPPORTED_ENTITIES.includes(entityType)) {
return NextResponse.json(
{ error: `Unsupported entity type: ${entityParam}. Supported: ${WEBHOOK_SUPPORTED_ENTITIES.join(', ')}` },
{ status: 400 }
);
}
const result = await manager.registerWebhook(entityType);
return NextResponse.json({ success: true, results: [result] });
}
// Register all supported entities
const results = await manager.registerAll();
const allSuccess = results.every(r => r.success);
return NextResponse.json({
success: allSuccess,
results,
summary: {
total: results.length,
succeeded: results.filter(r => r.success).length,
failed: results.filter(r => !r.success).length,
},
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.error('[WEBHOOK-REGISTER] Error:', msg);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
/**
* DELETE /api/webhooks/register Deregister all webhooks
* DELETE /api/webhooks/register?entity=Tickets Deregister a single entity
*/
export async function DELETE(request: NextRequest) {
try {
const manager = new AutotaskWebhookManager();
const { searchParams } = new URL(request.url);
const entityParam = searchParams.get('entity');
if (entityParam) {
const entityType = entityParam as WebhookEntityType;
if (!WEBHOOK_SUPPORTED_ENTITIES.includes(entityType)) {
return NextResponse.json(
{ error: `Unsupported entity type: ${entityParam}` },
{ status: 400 }
);
}
// Find the webhook ID from DB
const { postgresClient } = await import('@/lib/services/postgres-client');
const result = await postgresClient.query<{ autotask_webhook_id: string }>(
`SELECT autotask_webhook_id FROM webhook_configs WHERE entity_type = $1 AND autotask_webhook_id IS NOT NULL`,
[entityType]
);
if (result.rows.length === 0 || !result.rows[0].autotask_webhook_id) {
return NextResponse.json({ error: `No webhook registered for ${entityType}` }, { status: 404 });
}
await manager.deleteWebhook(entityType, parseInt(result.rows[0].autotask_webhook_id));
return NextResponse.json({ success: true, entityType });
}
// Deregister all
await manager.deregisterAll();
return NextResponse.json({ success: true, message: 'All webhooks deregistered' });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.error('[WEBHOOK-DEREGISTER] Error:', msg);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,30 @@
/**
* Webhook Status API
* GET /api/webhooks/status Show registered webhooks and their Autotask status
*/
import { NextResponse } from 'next/server';
import { AutotaskWebhookManager } from '@/lib/services/autotask-webhook-manager';
import { webhookService } from '@/lib/services/webhook-service';
export async function GET() {
try {
const manager = new AutotaskWebhookManager();
const [registrationStatus, stats] = await Promise.all([
manager.getStatus(),
webhookService.getWebhookStats(24),
]);
return NextResponse.json({
success: true,
registrations: registrationStatus,
stats,
webhookUrl: `${process.env.WEBHOOK_BASE_URL || ''}/api/webhooks/autotask`,
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.error('[WEBHOOK-STATUS] Error:', msg);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
import { ClassificationRuleInput } from '@/lib/types/workflow';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const result = await postgresClient.query(
`SELECT * FROM classification_rules WHERE id = $1`,
[id]
);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json(result.rows[0]);
} catch (error) {
console.error('Failed to fetch classification rule:', error);
return NextResponse.json({ error: 'Failed to fetch classification rule' }, { status: 500 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body: Partial<ClassificationRuleInput> = await request.json();
const fields: string[] = [];
const values: any[] = [];
let paramIndex = 1;
const fieldMap: Record<string, (v: any) => any> = {
name: v => v,
description: v => v,
rule_type: v => v,
sort_order: v => v,
is_active: v => v,
match_field: v => v,
match_operator: v => v,
match_value: v => JSON.stringify(v),
match_case_sensitive: v => v,
result_field: v => v,
result_value: v => JSON.stringify(v),
result_field_2: v => v,
result_value_2: v => v != null ? JSON.stringify(v) : null,
confidence: v => v,
stop_on_match: v => v,
};
for (const [key, transform] of Object.entries(fieldMap)) {
if (key in body) {
fields.push(`${key} = $${paramIndex}`);
values.push(transform((body as any)[key]));
paramIndex++;
}
}
if (fields.length === 0) {
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
}
fields.push(`updated_at = NOW()`);
values.push(id);
const result = await postgresClient.query(
`UPDATE classification_rules SET ${fields.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
values
);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json(result.rows[0]);
} catch (error) {
console.error('Failed to update classification rule:', error);
return NextResponse.json({ error: 'Failed to update classification rule' }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const result = await postgresClient.query(
`DELETE FROM classification_rules WHERE id = $1 RETURNING id`,
[id]
);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete classification rule:', error);
return NextResponse.json({ error: 'Failed to delete classification rule' }, { status: 500 });
}
}

View file

@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
import { ClassificationRuleInput } from '@/lib/types/workflow';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const ruleType = searchParams.get('rule_type');
let query = `SELECT * FROM classification_rules`;
const params: any[] = [];
if (ruleType) {
query += ` WHERE rule_type = $1`;
params.push(ruleType);
}
query += ` ORDER BY rule_type, sort_order`;
const result = await postgresClient.query(query, params);
return NextResponse.json(result.rows);
} catch (error) {
console.error('Failed to fetch classification rules:', error);
return NextResponse.json({ error: 'Failed to fetch classification rules' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body: ClassificationRuleInput = await request.json();
const result = await postgresClient.query(
`INSERT INTO classification_rules
(name, description, rule_type, sort_order, is_active, match_field, match_operator, match_value,
match_case_sensitive, result_field, result_value, result_field_2, result_value_2, confidence, stop_on_match)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING *`,
[
body.name,
body.description || null,
body.rule_type,
body.sort_order ?? 0,
body.is_active ?? true,
body.match_field,
body.match_operator,
JSON.stringify(body.match_value),
body.match_case_sensitive ?? false,
body.result_field,
JSON.stringify(body.result_value),
body.result_field_2 || null,
body.result_value_2 ? JSON.stringify(body.result_value_2) : null,
body.confidence ?? 'high',
body.stop_on_match ?? true,
]
);
return NextResponse.json(result.rows[0], { status: 201 });
} catch (error) {
console.error('Failed to create classification rule:', error);
return NextResponse.json({ error: 'Failed to create classification rule' }, { status: 500 });
}
}

View file

@ -0,0 +1,27 @@
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 [executionResult, stepsResult] = await Promise.all([
postgresClient.query(`SELECT * FROM workflow_executions WHERE id = $1`, [id]),
postgresClient.query(`SELECT * FROM workflow_execution_steps WHERE execution_id = $1 ORDER BY step_order`, [id]),
]);
if (executionResult.rows.length === 0) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json({
...executionResult.rows[0],
steps: stepsResult.rows,
});
} catch (error) {
console.error('Failed to fetch execution:', error);
return NextResponse.json({ error: 'Failed to fetch execution' }, { status: 500 });
}
}

View file

@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
const offset = parseInt(searchParams.get('offset') || '0');
const status = searchParams.get('status');
const method = searchParams.get('method');
const branch = searchParams.get('branch');
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (status) {
conditions.push(`status = $${paramIndex++}`);
params.push(status);
}
if (method) {
conditions.push(`classification_method = $${paramIndex++}`);
params.push(method);
}
if (branch) {
conditions.push(`branch = $${paramIndex++}`);
params.push(branch);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const [executions, countResult] = await Promise.all([
postgresClient.query(
`SELECT * FROM workflow_executions ${whereClause} ORDER BY created_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
[...params, limit, offset]
),
postgresClient.query(
`SELECT COUNT(*) as total FROM workflow_executions ${whereClause}`,
params
),
]);
return NextResponse.json({
data: executions.rows,
total: parseInt(countResult.rows[0].total),
limit,
offset,
});
} catch (error) {
console.error('Failed to fetch executions:', error);
return NextResponse.json({ error: 'Failed to fetch executions' }, { status: 500 });
}
}

View file

@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
import { WorkflowRuleInput } from '@/lib/types/workflow';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const ruleResult = await postgresClient.query(
`SELECT * FROM workflow_rules WHERE id = $1`, [id]
);
if (ruleResult.rows.length === 0) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const rule = ruleResult.rows[0];
const [conditions, actions] = await Promise.all([
postgresClient.query(`SELECT * FROM workflow_conditions WHERE rule_id = $1 ORDER BY condition_group, id`, [id]),
postgresClient.query(`SELECT * FROM workflow_actions WHERE rule_id = $1 ORDER BY sort_order`, [id]),
]);
return NextResponse.json({ ...rule, conditions: conditions.rows, actions: actions.rows });
} catch (error) {
console.error('Failed to fetch workflow rule:', error);
return NextResponse.json({ error: 'Failed to fetch workflow rule' }, { status: 500 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body: Partial<WorkflowRuleInput> = await request.json();
// Update rule fields
const fields: string[] = [];
const values: any[] = [];
let paramIndex = 1;
for (const key of ['name', 'description', 'is_active', 'sort_order', 'trigger_event', 'trigger_entity', 'stop_processing']) {
if (key in body) {
fields.push(`${key} = $${paramIndex}`);
values.push((body as any)[key]);
paramIndex++;
}
}
if (fields.length > 0) {
fields.push(`updated_at = NOW()`);
values.push(id);
await postgresClient.query(
`UPDATE workflow_rules SET ${fields.join(', ')} WHERE id = $${paramIndex}`,
values
);
}
// Replace conditions if provided
if (body.conditions) {
await postgresClient.query(`DELETE FROM workflow_conditions WHERE rule_id = $1`, [id]);
for (const cond of body.conditions) {
await postgresClient.query(
`INSERT INTO workflow_conditions (rule_id, condition_group, field, operator, value)
VALUES ($1, $2, $3, $4, $5)`,
[id, cond.condition_group ?? 0, cond.field, cond.operator, JSON.stringify(cond.value)]
);
}
}
// Replace actions if provided
if (body.actions) {
await postgresClient.query(`DELETE FROM workflow_actions WHERE rule_id = $1`, [id]);
for (const action of body.actions) {
await postgresClient.query(
`INSERT INTO workflow_actions (rule_id, sort_order, action_type, config)
VALUES ($1, $2, $3, $4)`,
[id, action.sort_order ?? 0, action.action_type, JSON.stringify(action.config ?? {})]
);
}
}
// Return updated rule
const ruleResult = await postgresClient.query(`SELECT * FROM workflow_rules WHERE id = $1`, [id]);
if (ruleResult.rows.length === 0) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const [conditions, actions] = await Promise.all([
postgresClient.query(`SELECT * FROM workflow_conditions WHERE rule_id = $1`, [id]),
postgresClient.query(`SELECT * FROM workflow_actions WHERE rule_id = $1 ORDER BY sort_order`, [id]),
]);
return NextResponse.json({ ...ruleResult.rows[0], conditions: conditions.rows, actions: actions.rows });
} catch (error) {
console.error('Failed to update workflow rule:', error);
return NextResponse.json({ error: 'Failed to update workflow rule' }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// CASCADE will delete conditions and actions
const result = await postgresClient.query(
`DELETE FROM workflow_rules WHERE id = $1 RETURNING id`, [id]
);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete workflow rule:', error);
return NextResponse.json({ error: 'Failed to delete workflow rule' }, { status: 500 });
}
}

View file

@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
import { WorkflowRuleInput } from '@/lib/types/workflow';
export async function GET() {
try {
const rules = await postgresClient.query(
`SELECT * FROM workflow_rules ORDER BY sort_order`
);
// Load conditions and actions for each rule
const rulesWithDetails = await Promise.all(
rules.rows.map(async (rule: any) => {
const [conditions, actions] = await Promise.all([
postgresClient.query(`SELECT * FROM workflow_conditions WHERE rule_id = $1 ORDER BY condition_group, id`, [rule.id]),
postgresClient.query(`SELECT * FROM workflow_actions WHERE rule_id = $1 ORDER BY sort_order`, [rule.id]),
]);
return { ...rule, conditions: conditions.rows, actions: actions.rows };
})
);
return NextResponse.json(rulesWithDetails);
} catch (error) {
console.error('Failed to fetch workflow rules:', error);
return NextResponse.json({ error: 'Failed to fetch workflow rules' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body: WorkflowRuleInput = await request.json();
// Insert rule
const ruleResult = await postgresClient.query(
`INSERT INTO workflow_rules (name, description, is_active, sort_order, trigger_event, trigger_entity, stop_processing)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
[
body.name,
body.description || null,
body.is_active ?? true,
body.sort_order ?? 0,
body.trigger_event,
body.trigger_entity ?? 'ticket',
body.stop_processing ?? false,
]
);
const rule = ruleResult.rows[0];
// Insert conditions
if (body.conditions?.length) {
for (const cond of body.conditions) {
await postgresClient.query(
`INSERT INTO workflow_conditions (rule_id, condition_group, field, operator, value)
VALUES ($1, $2, $3, $4, $5)`,
[rule.id, cond.condition_group ?? 0, cond.field, cond.operator, JSON.stringify(cond.value)]
);
}
}
// Insert actions
if (body.actions?.length) {
for (const action of body.actions) {
await postgresClient.query(
`INSERT INTO workflow_actions (rule_id, sort_order, action_type, config)
VALUES ($1, $2, $3, $4)`,
[rule.id, action.sort_order ?? 0, action.action_type, JSON.stringify(action.config ?? {})]
);
}
}
// Return full rule with conditions and actions
const [conditions, actions] = await Promise.all([
postgresClient.query(`SELECT * FROM workflow_conditions WHERE rule_id = $1`, [rule.id]),
postgresClient.query(`SELECT * FROM workflow_actions WHERE rule_id = $1 ORDER BY sort_order`, [rule.id]),
]);
return NextResponse.json(
{ ...rule, conditions: conditions.rows, actions: actions.rows },
{ status: 201 }
);
} catch (error) {
console.error('Failed to create workflow rule:', error);
return NextResponse.json({ error: 'Failed to create workflow rule' }, { status: 500 });
}
}

View file

@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET() {
try {
const result = await postgresClient.query(
`SELECT key, value, description FROM workflow_settings ORDER BY key`
);
const settings: Record<string, any> = {};
for (const row of result.rows) {
try {
settings[row.key] = { value: JSON.parse(row.value), description: row.description };
} catch {
settings[row.key] = { value: row.value, description: row.description };
}
}
return NextResponse.json(settings);
} catch (error) {
console.error('Failed to fetch settings:', error);
return NextResponse.json({ error: 'Failed to fetch settings' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const body: Record<string, any> = await request.json();
for (const [key, value] of Object.entries(body)) {
await postgresClient.query(
`UPDATE workflow_settings SET value = $1, updated_at = NOW() WHERE key = $2`,
[JSON.stringify(value), key]
);
}
// Return updated settings
const result = await postgresClient.query(
`SELECT key, value, description FROM workflow_settings ORDER BY key`
);
const settings: Record<string, any> = {};
for (const row of result.rows) {
try {
settings[row.key] = { value: JSON.parse(row.value), description: row.description };
} catch {
settings[row.key] = { value: row.value, description: row.description };
}
}
return NextResponse.json(settings);
} catch (error) {
console.error('Failed to update settings:', error);
return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 });
}
}

View file

@ -0,0 +1,87 @@
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 result = await postgresClient.query(
`SELECT * FROM ai_prompt_templates WHERE id = $1`, [id]
);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json(result.rows[0]);
} catch (error) {
console.error('Failed to fetch template:', error);
return NextResponse.json({ error: 'Failed to fetch template' }, { status: 500 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const fields: string[] = [];
const values: any[] = [];
let paramIndex = 1;
for (const key of ['name', 'purpose', 'system_prompt', 'user_prompt_template', 'provider', 'model', 'temperature', 'max_tokens', 'is_active']) {
if (key in body) {
fields.push(`${key} = $${paramIndex}`);
values.push(body[key]);
paramIndex++;
}
}
if (fields.length === 0) {
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
}
fields.push(`updated_at = NOW()`);
values.push(id);
const result = await postgresClient.query(
`UPDATE ai_prompt_templates SET ${fields.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
values
);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json(result.rows[0]);
} catch (error) {
console.error('Failed to update template:', error);
return NextResponse.json({ error: 'Failed to update template' }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const result = await postgresClient.query(
`DELETE FROM ai_prompt_templates WHERE id = $1 RETURNING id`, [id]
);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete template:', error);
return NextResponse.json({ error: 'Failed to delete template' }, { status: 500 });
}
}

View file

@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
import { AiPromptTemplateInput } from '@/lib/types/workflow';
export async function GET() {
try {
const result = await postgresClient.query(
`SELECT * FROM ai_prompt_templates ORDER BY purpose, version DESC`
);
return NextResponse.json(result.rows);
} catch (error) {
console.error('Failed to fetch templates:', error);
return NextResponse.json({ error: 'Failed to fetch templates' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body: AiPromptTemplateInput = await request.json();
const result = await postgresClient.query(
`INSERT INTO ai_prompt_templates
(name, purpose, system_prompt, user_prompt_template, provider, model, temperature, max_tokens, is_active)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
[
body.name,
body.purpose,
body.system_prompt,
body.user_prompt_template,
body.provider ?? 'openai',
body.model ?? 'gpt-4o',
body.temperature ?? 0.3,
body.max_tokens ?? 4000,
body.is_active ?? true,
]
);
return NextResponse.json(result.rows[0], { status: 201 });
} catch (error) {
console.error('Failed to create template:', error);
return NextResponse.json({ error: 'Failed to create template' }, { status: 500 });
}
}

View file

@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
import { workflowEngine } from '@/lib/services/workflow-engine';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const ticketId = body.ticket_id;
if (!ticketId) {
return NextResponse.json({ error: 'ticket_id is required' }, { status: 400 });
}
const result = await workflowEngine.dryRun(ticketId);
return NextResponse.json(result);
} catch (error) {
console.error('Failed to run workflow test:', error);
return NextResponse.json({ error: 'Failed to run workflow test' }, { status: 500 });
}
}

View file

@ -4,81 +4,106 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Separator } from '@/components/ui/separator';
import { Calendar, Check, X, Copy, CheckCircle2, Code2, LayoutTemplate, ExternalLink, Phone, Globe, MapPin, Mail } from 'lucide-react';
import { Calendar, Check, X, Copy, CheckCircle2, Code2, LayoutTemplate, ExternalLink, Phone, Globe, Loader2, User, Building2, MessageSquare, Clock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useState } from 'react';
import { useState, useEffect } from 'react';
// ── Autotask label maps ────────────────────────────────────────────────────────
// ── Static picklist maps (Autotask standard values from DB) ──────────────────
const TICKET_STATUS: Record<number, string> = {
1: 'New', 5: 'Complete', 8: 'In Progress', 9: 'Waiting Customer',
10: 'Waiting Materials', 11: 'Waiting Vendor', 12: 'Waiting Parts',
13: 'Scheduled', 14: 'Escalated', 16: 'Waiting on 3rd Party',
29: 'Customer Responded', 30: 'Dispatched', 31: 'Resolved',
const PRIORITY_MAP: Record<number, { label: string; cls: string }> = {
2: { label: 'Critical', cls: 'bg-red-500/15 text-red-600 border border-red-500/30' },
3: { label: 'High', cls: 'bg-orange-500/15 text-orange-600 border border-orange-500/30' },
4: { label: 'Medium', cls: 'bg-yellow-500/15 text-yellow-700 border border-yellow-500/30' },
6: { label: 'Low', cls: 'bg-blue-500/15 text-blue-600 border border-blue-500/30' },
7: { label: 'Very Low', cls: 'bg-slate-500/15 text-slate-500 border border-slate-500/30' },
8: { label: 'Critical', cls: 'bg-red-500/15 text-red-600 border border-red-500/30' },
9: { label: 'High', cls: 'bg-orange-500/15 text-orange-600 border border-orange-500/30' },
10: { label: 'Medium', cls: 'bg-yellow-500/15 text-yellow-700 border border-yellow-500/30' },
11: { label: 'Low', cls: 'bg-blue-500/15 text-blue-600 border border-blue-500/30' },
};
const TICKET_PRIORITY: Record<number, { label: string; variant: 'destructive' | 'default' | 'secondary' | 'outline' }> = {
1: { label: 'Critical', variant: 'destructive' },
2: { label: 'High', variant: 'default' },
3: { label: 'Medium', variant: 'secondary' },
4: { label: 'Low', variant: 'outline' },
const STATUS_COLOR: Record<string, string> = {
'New': 'bg-blue-500/15 text-blue-600 border border-blue-500/30',
'In Progress': 'bg-indigo-500/15 text-indigo-600 border border-indigo-500/30',
'Complete': 'bg-green-500/15 text-green-600 border border-green-500/30',
'Waiting Customer': 'bg-amber-500/15 text-amber-700 border border-amber-500/30',
'Waiting Materials': 'bg-orange-500/15 text-orange-600 border border-orange-500/30',
'Waiting Vendor': 'bg-orange-500/15 text-orange-600 border border-orange-500/30',
'Waiting Approval': 'bg-purple-500/15 text-purple-600 border border-purple-500/30',
'On Hold': 'bg-slate-500/15 text-slate-500 border border-slate-500/30',
'Escalate': 'bg-red-500/15 text-red-600 border border-red-500/30',
'Escalate to Wulf': 'bg-red-500/15 text-red-600 border border-red-500/30',
'Escalate to MC': 'bg-red-500/15 text-red-600 border border-red-500/30',
'Resource Assigned': 'bg-cyan-500/15 text-cyan-600 border border-cyan-500/30',
'Service Call Scheduled': 'bg-teal-500/15 text-teal-600 border border-teal-500/30',
'Dispatched': 'bg-teal-500/15 text-teal-600 border border-teal-500/30',
'Resolved <CSAT Survey>': 'bg-green-500/15 text-green-600 border border-green-500/30',
};
const TICKET_SOURCE: Record<number, string> = {
1: 'Phone', 2: 'Email', 5: 'Web Portal', 6: 'Monitoring Alert',
8: 'RMM Alert', 9: 'Chat', 10: 'In Person', 14: 'API',
const SOURCE_MAP: Record<number, string> = {
[-2]: 'System', [-1]: 'Internal',
1: 'Phone', 2: 'Email', 4: 'Web Portal', 6: 'Monitoring Alert',
8: 'RMM Alert', 17: 'Chat', 21: 'API', 22: 'Automation',
27: 'In Person', 29: 'Client Portal', 30: 'Microsoft Teams',
31: 'Webhook', 33: 'Datto RMM', 34: 'Rewst', 35: 'TimeZest',
36: 'DeskDirector', 38: 'Huntress', 39: 'Blumira', 40: 'SentinelOne',
};
const QUEUE: Record<number, string> = {
29482833: 'Client Services', 29482834: 'Network Operations',
29482835: 'Help Desk', 29482836: 'Projects',
const COMPANY_TYPE_MAP: Record<number, { label: string; cls: string }> = {
1: { label: 'Customer', cls: 'bg-green-500/15 text-green-600 border border-green-500/30' },
2: { label: 'Lead', cls: 'bg-blue-500/15 text-blue-600 border border-blue-500/30' },
3: { label: 'Prospect', cls: 'bg-purple-500/15 text-purple-600 border border-purple-500/30' },
4: { label: 'Dead', cls: 'bg-slate-500/15 text-slate-500 border border-slate-500/30' },
6: { label: 'Cancelation', cls: 'bg-red-500/15 text-red-600 border border-red-500/30' },
7: { label: 'Vendor', cls: 'bg-orange-500/15 text-orange-600 border border-orange-500/30' },
8: { label: 'Partner', cls: 'bg-cyan-500/15 text-cyan-600 border border-cyan-500/30' },
};
const COMPANY_TYPE: Record<number, string> = {
1: 'Customer', 2: 'Lead', 3: 'Prospect', 4: 'Dead', 6: 'Cancelation',
7: 'Vendor', 8: 'Partner',
};
// ── Live lookup types (fetched from DB) ───────────────────────────────────────
interface Lookups {
statuses: Record<number, string>;
resources: Record<number, string>;
companies: Record<number, string>;
issueTypes: Record<number, string>;
subIssueTypes: Record<number, string>;
queues: Record<number, string>;
configItems: Record<number, string>;
}
// ── Field metadata for formatted view ─────────────────────────────────────────
type FieldType = 'date' | 'bool' | 'status' | 'priority' | 'source' | 'queue' | 'company_type' | 'url' | 'phone' | 'hours' | 'id' | 'resource' | 'company' | 'issue_type' | 'sub_issue_type' | 'config_item';
type FieldGroup = {
label: string;
fields: Array<{
key: string;
label: string;
type?: 'date' | 'bool' | 'status' | 'priority' | 'source' | 'queue' | 'company_type' | 'url' | 'phone' | 'email' | 'text' | 'hours' | 'id';
}>;
fields: Array<{ key: string; label: string; type?: FieldType }>;
paired?: string;
};
const TICKET_GROUPS: FieldGroup[] = [
{
label: 'Overview',
label: 'Parties',
fields: [
{ key: 'company_id', label: 'Company', type: 'company' },
{ key: 'contact_id', label: 'Contact ID', type: 'id' },
{ key: 'assigned_resource_id', label: 'Assigned Resource', type: 'resource' },
{ key: 'configuration_item_id', label: 'Configuration Item', type: 'config_item' },
],
},
{
label: 'Details',
fields: [
{ key: 'ticket_number', label: 'Ticket #' },
{ key: 'title', label: 'Title' },
{ key: 'status', label: 'Status', type: 'status' },
{ key: 'priority', label: 'Priority', type: 'priority' },
{ key: 'source', label: 'Source', type: 'source' },
{ key: 'queue_id', label: 'Queue', type: 'queue' },
],
},
{
label: 'Parties',
fields: [
{ key: 'company_id', label: 'Company ID', type: 'id' },
{ key: 'contact_id', label: 'Contact ID', type: 'id' },
{ key: 'assigned_resource_id', label: 'Assigned Resource ID', type: 'id' },
],
},
{
label: 'Classification',
fields: [
{ key: 'issue_type', label: 'Issue Type' },
{ key: 'sub_issue_type', label: 'Sub-Issue Type' },
{ key: 'issue_type', label: 'Issue Type', type: 'issue_type' },
{ key: 'sub_issue_type', label: 'Sub-Issue Type', type: 'sub_issue_type' },
],
},
{
label: 'Dates & Time',
paired: 'System',
fields: [
{ key: 'create_date', label: 'Created', type: 'date' },
{ key: 'due_date_time', label: 'Due', type: 'date' },
@ -89,6 +114,7 @@ const TICKET_GROUPS: FieldGroup[] = [
},
{
label: 'System',
paired: 'Dates & Time',
fields: [
{ key: 'id', label: 'Record ID', type: 'id' },
{ key: 'synced_at', label: 'Synced At', type: 'date' },
@ -130,6 +156,7 @@ const COMPANY_GROUPS: FieldGroup[] = [
},
{
label: 'System',
paired: 'Address',
fields: [
{ key: 'id', label: 'Record ID', type: 'id' },
{ key: 'synced_at', label: 'Synced At', type: 'date' },
@ -140,23 +167,24 @@ const COMPANY_GROUPS: FieldGroup[] = [
// ── Helpers ────────────────────────────────────────────────────────────────────
function resolveLabel(key: string, value: any, type?: string): { display: React.ReactNode; isEmpty: boolean } {
function ColorBadge({ cls, children }: { cls: string; children: React.ReactNode }) {
return <span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${cls}`}>{children}</span>;
}
function resolveLabel(key: string, value: any, type: FieldType | undefined, lookups: Lookups): { display: React.ReactNode; isEmpty: boolean } {
if (value === null || value === undefined || value === '') {
return { display: <span className="text-muted-foreground/50 italic text-xs"></span>, isEmpty: true };
return { display: <span className="text-muted-foreground/40 italic text-xs"></span>, isEmpty: true };
}
switch (type) {
case 'bool':
return {
display: (
<Badge variant={value ? 'default' : 'secondary'} className="gap-1">
{value ? <Check className="w-3 h-3" /> : <X className="w-3 h-3" />}
{value ? 'Yes' : 'No'}
</Badge>
),
display: value
? <ColorBadge cls="bg-green-500/15 text-green-600 border border-green-500/30"><Check className="w-3 h-3 mr-1" />Yes</ColorBadge>
: <ColorBadge cls="bg-slate-500/15 text-slate-500 border border-slate-500/30"><X className="w-3 h-3 mr-1" />No</ColorBadge>,
isEmpty: false,
};
case 'date':
case 'date': {
try {
const d = new Date(value);
return {
@ -164,39 +192,74 @@ function resolveLabel(key: string, value: any, type?: string): { display: React.
<span className="inline-flex items-center gap-1.5 text-sm">
<Calendar className="w-3.5 h-3.5 text-muted-foreground" />
{d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}
<span className="text-muted-foreground text-xs">{d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</span>
</span>
),
isEmpty: false,
};
} catch { break; }
}
case 'status': {
const label = TICKET_STATUS[Number(value)] ?? `Status ${value}`;
return { display: <Badge variant="outline">{label}</Badge>, isEmpty: false };
const label = lookups.statuses[Number(value)] ?? `Status ${value}`;
const cls = STATUS_COLOR[label] ?? 'bg-muted text-muted-foreground border border-border';
return { display: <ColorBadge cls={cls}>{label}</ColorBadge>, isEmpty: false };
}
case 'priority': {
const p = TICKET_PRIORITY[Number(value)];
return { display: <Badge variant={p?.variant ?? 'secondary'}>{p?.label ?? `Priority ${value}`}</Badge>, isEmpty: false };
const p = PRIORITY_MAP[Number(value)];
return { display: <ColorBadge cls={p?.cls ?? 'bg-muted text-muted-foreground border border-border'}>{p?.label ?? `Priority ${value}`}</ColorBadge>, isEmpty: false };
}
case 'source': {
const label = TICKET_SOURCE[Number(value)] ?? `Source ${value}`;
return { display: <Badge variant="secondary">{label}</Badge>, isEmpty: false };
const label = SOURCE_MAP[Number(value)] ?? `Source ${value}`;
return { display: <ColorBadge cls="bg-violet-500/15 text-violet-600 border border-violet-500/30">{label}</ColorBadge>, isEmpty: false };
}
case 'queue': {
const label = QUEUE[Number(value)] ?? `Queue ${value}`;
return { display: <span className="text-sm font-medium">{label}</span>, isEmpty: false };
const qLabel = lookups.queues[Number(value)] ?? `Queue ${value}`;
return { display: <ColorBadge cls="bg-indigo-500/15 text-indigo-600 border border-indigo-500/30">{qLabel}</ColorBadge>, isEmpty: false };
}
case 'company_type': {
const label = COMPANY_TYPE[Number(value)] ?? `Type ${value}`;
return { display: <Badge variant="outline">{label}</Badge>, isEmpty: false };
const ct = COMPANY_TYPE_MAP[Number(value)];
return { display: <ColorBadge cls={ct?.cls ?? 'bg-muted text-muted-foreground border border-border'}>{ct?.label ?? `Type ${value}`}</ColorBadge>, isEmpty: false };
}
case 'resource': {
const name = lookups.resources[Number(value)];
return {
display: name
? <span className="inline-flex items-center gap-1.5 text-sm"><User className="w-3.5 h-3.5 text-muted-foreground" />{name}</span>
: <span className="font-mono text-xs bg-muted px-2 py-0.5 rounded">{value}</span>,
isEmpty: false,
};
}
case 'company': {
const name = lookups.companies[Number(value)];
return {
display: name
? <span className="inline-flex items-center gap-1.5 text-sm"><Building2 className="w-3.5 h-3.5 text-muted-foreground" />{name}</span>
: <span className="font-mono text-xs bg-muted px-2 py-0.5 rounded">{value}</span>,
isEmpty: false,
};
}
case 'issue_type': {
const label = lookups.issueTypes[Number(value)] ?? `Issue ${value}`;
return { display: <ColorBadge cls="bg-sky-500/15 text-sky-600 border border-sky-500/30">{label}</ColorBadge>, isEmpty: false };
}
case 'sub_issue_type': {
const label = lookups.subIssueTypes[Number(value)] ?? `Sub-Issue ${value}`;
return { display: <ColorBadge cls="bg-sky-500/10 text-sky-500 border border-sky-500/20">{label}</ColorBadge>, isEmpty: false };
}
case 'config_item': {
const name = lookups.configItems[Number(value)];
return {
display: name
? <span className="text-sm">{name}</span>
: <span className="font-mono text-xs bg-muted px-2 py-0.5 rounded">{value}</span>,
isEmpty: false,
};
}
case 'url':
return {
display: (
<a href={String(value).startsWith('http') ? value : `https://${value}`} target="_blank" rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-blue-500 hover:underline">
<Globe className="w-3.5 h-3.5" />{value}
<ExternalLink className="w-3 h-3" />
<Globe className="w-3.5 h-3.5" />{value}<ExternalLink className="w-3 h-3" />
</a>
),
isEmpty: false,
@ -217,7 +280,7 @@ function resolveLabel(key: string, value: any, type?: string): { display: React.
}
if (typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}/)) {
return resolveLabel(key, value, 'date');
return resolveLabel(key, value, 'date', lookups);
}
return { display: <span className="text-sm">{String(value)}</span>, isEmpty: false };
@ -229,6 +292,8 @@ function detectGroups(data: Record<string, any>): FieldGroup[] {
return [{ label: 'Fields', fields: Object.keys(data).map(k => ({ key: k, label: k })) }];
}
const EMPTY_LOOKUPS: Lookups = { statuses: {}, resources: {}, companies: {}, issueTypes: {}, subIssueTypes: {}, queues: {}, configItems: {} };
// ── Component ──────────────────────────────────────────────────────────────────
interface DetailModalProps {
@ -241,6 +306,51 @@ interface DetailModalProps {
export default function DetailModal({ open, onOpenChange, title, data, fields }: DetailModalProps) {
const [copiedField, setCopiedField] = useState<string | null>(null);
const [lookups, setLookups] = useState<Lookups>(EMPTY_LOOKUPS);
const [lookupsLoading, setLookupsLoading] = useState(false);
const [notes, setNotes] = useState<any[]>([]);
const [notesLoading, setNotesLoading] = useState(false);
const [timeEntries, setTimeEntries] = useState<any[]>([]);
const [timeEntriesLoading, setTimeEntriesLoading] = useState(false);
useEffect(() => {
if (!open) return;
setLookupsLoading(true);
fetch('/api/data/lookups')
.then(r => r.json())
.then(d => {
setLookups({
statuses: Object.fromEntries((d.statuses ?? []).map((r: any) => [r.value, r.label])),
resources: Object.fromEntries((d.resources ?? []).map((r: any) => [r.id, r.name])),
companies: Object.fromEntries((d.companies ?? []).map((r: any) => [r.id, r.name])),
issueTypes: Object.fromEntries((d.issueTypes ?? []).map((r: any) => [r.value, r.label])),
subIssueTypes:Object.fromEntries((d.subIssueTypes?? []).map((r: any) => [r.value, r.label])),
queues: Object.fromEntries((d.queues ?? []).map((r: any) => [r.value, r.label])),
configItems: Object.fromEntries((d.configItems ?? []).map((r: any) => [r.id, r.name])),
});
})
.catch(() => {})
.finally(() => setLookupsLoading(false));
if (data && 'ticket_number' in data && data.id) {
setNotesLoading(true);
fetch(`/api/data/ticket-notes?ticket_id=${data.id}&sort_by=create_date_time&sort_order=asc&limit=200`)
.then(r => r.json())
.then(d => setNotes(d.ticketNotes ?? []))
.catch(() => setNotes([]))
.finally(() => setNotesLoading(false));
setTimeEntriesLoading(true);
fetch(`/api/data/time-entries?ticket_id=${data.id}&sort_by=entry_date&sort_order=asc&limit=200`)
.then(r => r.json())
.then(d => setTimeEntries(d.timeEntries ?? []))
.catch(() => setTimeEntries([]))
.finally(() => setTimeEntriesLoading(false));
} else {
setNotes([]);
setTimeEntries([]);
}
}, [open]);
if (!data) return null;
@ -264,10 +374,27 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-7xl max-h-[90vh] overflow-hidden flex flex-col gap-0 p-0">
<DialogContent className="max-w-7xl max-h-[90vh] overflow-hidden flex flex-col gap-0 p-0 border-2 border-blue-500/70 shadow-[0_0_0_1px_rgba(59,130,246,0.15),0_20px_60px_-10px_rgba(59,130,246,0.25)]">
{/* Header */}
<div className="px-6 pt-6 pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div className="px-6 pt-5 pb-4 border-b">
{'ticket_number' in data ? (
<div className="flex items-start justify-between gap-6 pr-8">
<div className="min-w-0">
<div className="flex items-baseline gap-2 flex-wrap">
<span className="font-mono text-sm font-semibold text-muted-foreground shrink-0">{data.ticket_number}</span>
<DialogTitle className="text-xl font-bold leading-tight" style={{overflowWrap:'anywhere', wordBreak:'break-word'}}>{data.title}</DialogTitle>
</div>
</div>
<div className="shrink-0 flex flex-col items-end gap-1">
{(() => {
const label = lookups.statuses[Number(data.status)] ?? `Status ${data.status}`;
const cls = STATUS_COLOR[label] ?? 'bg-muted text-muted-foreground border border-border';
return <ColorBadge cls={cls}>{label}</ColorBadge>;
})()}
</div>
</div>
) : (
<div className="flex items-start justify-between gap-4 pr-8">
<div>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription className="mt-1">
@ -275,16 +402,12 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
</DialogDescription>
</div>
{'is_active' in data && (
<Badge variant={data.is_active ? 'default' : 'secondary'} className="shrink-0 mt-1">
<ColorBadge cls={data.is_active ? 'bg-green-500/15 text-green-600 border border-green-500/30' : 'bg-slate-500/15 text-slate-500 border border-slate-500/30'}>
{data.is_active ? 'Active' : 'Inactive'}
</Badge>
)}
{'status' in data && (
<Badge variant="outline" className="shrink-0 mt-1">
{TICKET_STATUS[Number(data.status)] ?? `Status ${data.status}`}
</Badge>
</ColorBadge>
)}
</div>
)}
</div>
{/* Tabs */}
@ -295,6 +418,26 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
<LayoutTemplate className="w-3.5 h-3.5" />
Formatted
</TabsTrigger>
{'ticket_number' in data && (
<TabsTrigger value="time" className="gap-1.5">
<Clock className="w-3.5 h-3.5" />
Time
{timeEntries.length > 0 && (
<span className="ml-1 rounded-full bg-blue-500/20 text-blue-600 text-xs px-1.5 py-0.5 font-medium">
{timeEntries.reduce((s, e) => s + (parseFloat(e.hours_worked) || 0), 0).toFixed(1)}h
</span>
)}
</TabsTrigger>
)}
{'ticket_number' in data && (
<TabsTrigger value="notes" className="gap-1.5">
<MessageSquare className="w-3.5 h-3.5" />
Notes
{notes.length > 0 && (
<span className="ml-1 rounded-full bg-blue-500/20 text-blue-600 text-xs px-1.5 py-0.5 font-medium">{notes.length}</span>
)}
</TabsTrigger>
)}
<TabsTrigger value="raw" className="gap-1.5">
<Code2 className="w-3.5 h-3.5" />
Raw
@ -305,16 +448,100 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
{/* ── Formatted Tab ── */}
<TabsContent value="formatted" className="flex-1 overflow-y-auto px-6 py-4 mt-0">
<div className="space-y-6">
{groups.map((group) => {
{(() => {
const rendered = new Set<string>();
return groups.map((group) => {
if (rendered.has(group.label)) return null;
const visibleFields = group.fields.filter(f => f.key in data);
if (visibleFields.length === 0) return null;
// Inline badge row for Details group
if (group.label === 'Details') {
return (
<div key="Details">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">Details</h3>
<div className="rounded-lg border overflow-hidden">
<div className="flex flex-wrap gap-x-6 gap-y-3 px-4 py-3">
{visibleFields.map((field) => {
const value = data[field.key];
const { display, isEmpty } = resolveLabel(field.key, value, field.type, lookups);
if (isEmpty) return null;
return (
<div key={field.key} className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{field.label}:</span>
{display}
</div>
);
})}
</div>
</div>
</div>
);
}
const pairedGroup = group.paired ? groups.find(g => g.label === group.paired) : null;
const pairedVisible = pairedGroup ? pairedGroup.fields.filter(f => f.key in data) : [];
const isPaired = !!pairedGroup && pairedVisible.length > 0;
if (isPaired) {
rendered.add(group.label);
rendered.add(pairedGroup!.label);
}
const renderGroupTable = (g: FieldGroup, fields: typeof visibleFields) => (
<div key={g.label} className="flex-1 min-w-0">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">{g.label}</h3>
<div className="rounded-lg border overflow-hidden">
{fields.map((field, idx) => {
const value = data[field.key];
const { display, isEmpty } = resolveLabel(field.key, value, field.type, lookups);
const stringValue = value !== null && value !== undefined ? String(value) : '';
return (
<div key={field.key}>
{idx > 0 && <Separator />}
<div className="group grid grid-cols-[140px_1fr] items-start">
<div className="px-3 py-2.5 bg-muted/40 text-xs font-medium text-muted-foreground border-r truncate">
{field.label}
</div>
<div className="px-3 py-2.5 flex items-start justify-between gap-2 min-w-0 overflow-hidden">
<div className={`flex-1 min-w-0 ${isEmpty ? 'opacity-40' : ''}`} style={{overflowWrap:'anywhere', wordBreak:'break-word'}}>
{display}
</div>
{stringValue && !isEmpty && (
<Button variant="ghost" size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={() => copyToClipboard(stringValue, `${g.label}-${field.key}`)}
>
{copiedField === `${g.label}-${field.key}`
? <CheckCircle2 className="w-3 h-3 text-green-500" />
: <Copy className="w-3 h-3" />}
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
if (isPaired) {
return (
<div key={group.label} className="grid grid-cols-2 gap-4">
{renderGroupTable(group, visibleFields)}
{renderGroupTable(pairedGroup!, pairedVisible)}
</div>
);
}
return (
<div key={group.label}>
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">{group.label}</h3>
<div className="rounded-lg border overflow-hidden">
{visibleFields.map((field, idx) => {
const value = data[field.key];
const { display, isEmpty } = resolveLabel(field.key, value, field.type);
const { display, isEmpty } = resolveLabel(field.key, value, field.type, lookups);
const stringValue = value !== null && value !== undefined ? String(value) : '';
return (
<div key={field.key}>
@ -323,14 +550,12 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
<div className="px-4 py-3 bg-muted/40 text-sm font-medium text-muted-foreground border-r">
{field.label}
</div>
<div className="px-4 py-3 flex items-start justify-between gap-2 min-w-0">
<div className={`flex-1 min-w-0 break-words ${isEmpty ? 'opacity-40' : ''}`}>
<div className="px-4 py-3 flex items-start justify-between gap-2 min-w-0 overflow-hidden">
<div className={`flex-1 min-w-0 ${isEmpty ? 'opacity-40' : ''}`} style={{overflowWrap:'anywhere', wordBreak:'break-word'}}>
{display}
</div>
{stringValue && !isEmpty && (
<Button
variant="ghost"
size="icon"
<Button variant="ghost" size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={() => copyToClipboard(stringValue, field.key)}
>
@ -347,13 +572,14 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
</div>
</div>
);
})}
});
})()}
{/* Description block for tickets */}
{'description' in data && data.description && (
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">Description</h3>
<div className="rounded-lg border p-4 text-sm whitespace-pre-wrap leading-relaxed text-muted-foreground">
<div className="rounded-lg border p-4 text-sm whitespace-pre-wrap leading-relaxed text-muted-foreground break-words overflow-hidden" style={{overflowWrap:'anywhere', wordBreak:'break-word'}}>
{data.description}
</div>
</div>
@ -361,6 +587,137 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
</div>
</TabsContent>
{/* ── Time Entries Tab ── */}
<TabsContent value="time" className="flex-1 overflow-y-auto px-6 py-4 mt-0">
{timeEntriesLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : timeEntries.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground gap-2">
<Clock className="w-8 h-8 opacity-30" />
<p className="text-sm">No time entries on this ticket</p>
</div>
) : (
<div className="space-y-2">
{/* Summary bar */}
<div className="rounded-lg border px-4 py-3 flex items-center gap-6 bg-muted/30 mb-4">
<div className="flex items-center gap-1.5 text-sm">
<Clock className="w-4 h-4 text-muted-foreground" />
<span className="font-semibold">{timeEntries.reduce((s, e) => s + (parseFloat(e.hours_worked) || 0), 0).toFixed(2)}</span>
<span className="text-muted-foreground">total hours</span>
</div>
<div className="text-sm text-muted-foreground">{timeEntries.length} {timeEntries.length === 1 ? 'entry' : 'entries'}</div>
<div className="text-sm text-muted-foreground">
{timeEntries.filter(e => e.billable).length} billable
</div>
</div>
{/* Entry rows */}
<div className="rounded-lg border overflow-hidden">
{timeEntries.map((entry, idx) => (
<div key={entry.id}>
{idx > 0 && <Separator />}
<div className="px-4 py-3 grid grid-cols-[1fr_auto] gap-4 items-start">
<div className="space-y-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{entry.resource_name && (
<span className="inline-flex items-center gap-1 text-sm font-medium">
<User className="w-3.5 h-3.5 text-muted-foreground" />
{entry.resource_name}
</span>
)}
{entry.billable && (
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-green-500/15 text-green-600 border border-green-500/30">Billable</span>
)}
{entry.approved && (
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-blue-500/15 text-blue-600 border border-blue-500/30">Approved</span>
)}
</div>
{entry.notes && (
<p className="text-sm text-muted-foreground" style={{overflowWrap:'anywhere', wordBreak:'break-word'}}>{entry.notes}</p>
)}
</div>
<div className="shrink-0 flex flex-col items-end gap-1">
<span className="text-sm font-semibold tabular-nums">
{parseFloat(entry.hours_worked).toFixed(2)}h
</span>
{entry.entry_date && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Calendar className="w-3 h-3" />
{new Date(entry.entry_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
</TabsContent>
{/* ── Notes Tab ── */}
<TabsContent value="notes" className="flex-1 overflow-y-auto px-6 py-4 mt-0">
{notesLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : notes.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground gap-2">
<MessageSquare className="w-8 h-8 opacity-30" />
<p className="text-sm">No notes on this ticket</p>
</div>
) : (
<div className="space-y-3">
{notes.map((note) => {
const publishCls: Record<number, string> = {
1: 'bg-green-500/15 text-green-600 border border-green-500/30',
2: 'bg-amber-500/15 text-amber-700 border border-amber-500/30',
4: 'bg-slate-500/15 text-slate-500 border border-slate-500/30',
};
const publishLabel: Record<number, string> = {
1: 'All Users', 2: 'Internal', 4: 'Internal Only',
};
return (
<div key={note.id} className="rounded-lg border p-4 space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap">
{note.creator_name && (
<span className="inline-flex items-center gap-1 text-sm font-medium">
<User className="w-3.5 h-3.5 text-muted-foreground" />
{note.creator_name}
</span>
)}
{note.publish != null && (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${publishCls[note.publish] ?? 'bg-muted text-muted-foreground border border-border'}`}>
{publishLabel[note.publish] ?? `Publish ${note.publish}`}
</span>
)}
{note.title && (
<span className="text-sm font-semibold text-foreground">{note.title}</span>
)}
</div>
{note.create_date_time && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground shrink-0">
<Calendar className="w-3 h-3" />
{new Date(note.create_date_time).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}
{' '}
{new Date(note.create_date_time).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</div>
{note.description && (
<div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed border-t pt-2" style={{overflowWrap:'anywhere', wordBreak:'break-word'}}>
{note.description}
</div>
)}
</div>
);
})}
</div>
)}
</TabsContent>
{/* ── Raw Tab ── */}
<TabsContent value="raw" className="flex-1 overflow-y-auto px-6 py-4 mt-0">
<div className="rounded-lg border overflow-hidden">

View file

@ -0,0 +1,290 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import {
Shield, Monitor, Network, Apple,
CheckCircle2, XCircle, AlertTriangle, RefreshCw, Loader2,
Server, HardDrive, Cpu, Clock,
} from 'lucide-react';
function StatusDot({ ok, warn }: { ok: boolean; warn?: boolean }) {
if (!ok) return <span className="inline-block w-2 h-2 rounded-full bg-red-500" />;
if (warn) return <span className="inline-block w-2 h-2 rounded-full bg-yellow-500" />;
return <span className="inline-block w-2 h-2 rounded-full bg-green-500" />;
}
function StatCard({ label, value, sub, icon: Icon, cls }: {
label: string; value: string | number; sub?: string;
icon?: React.ElementType; cls?: string;
}) {
return (
<div className={`rounded-lg border p-4 flex flex-col gap-1 ${cls ?? ''}`}>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{Icon && <Icon className="w-3.5 h-3.5" />}
{label}
</div>
<div className="text-2xl font-bold tabular-nums">{value}</div>
{sub && <div className="text-xs text-muted-foreground">{sub}</div>}
</div>
);
}
function fmtDate(d: string | null) {
if (!d) return 'Never';
return new Date(d).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function VeeamTab({ data, onSync, syncing }: { data: any; onSync: () => void; syncing: boolean }) {
if (!data) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
const aj = data.agentJobs ?? {};
const bj = data.backupJobs ?? {};
const totalFailed = (aj.failed ?? 0) + (bj.failed ?? 0);
const totalWarning = (aj.warning ?? 0) + (bj.warning ?? 0);
const totalRunning = aj.running ?? 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<StatusDot ok={data.configured} warn={totalFailed > 0 || totalWarning > 0} />
<div>
<p className="text-sm font-medium">{data.configured ? 'Connected to VSPC' : 'Not configured'}</p>
<p className="text-xs text-muted-foreground">Last sync: {fmtDate(data.lastSync)}</p>
</div>
</div>
<Button size="sm" onClick={onSync} disabled={syncing || !data.configured}>
{syncing ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Sync Now
</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="Organizations" value={data.organizations ?? 0} icon={Server} />
<StatCard label="Protected Workloads" value={data.protectedWorkloads ?? 0} icon={HardDrive} />
<StatCard label="Agent Jobs" value={aj.total ?? 0} sub={`${aj.success ?? 0} success`} icon={Shield} />
<StatCard label="Backup Jobs" value={bj.total ?? 0} sub={`${bj.success ?? 0} success`} icon={Shield} />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="Running" value={totalRunning} icon={Clock}
cls={totalRunning > 0 ? 'border-blue-500/30 bg-blue-500/5' : ''} />
<StatCard label="Failed" value={totalFailed} icon={XCircle}
cls={totalFailed > 0 ? 'border-red-500/30 bg-red-500/5' : ''} />
<StatCard label="Warning" value={totalWarning} icon={AlertTriangle}
cls={totalWarning > 0 ? 'border-yellow-500/30 bg-yellow-500/5' : ''} />
<StatCard label="Success" value={(aj.success ?? 0) + (bj.success ?? 0)} icon={CheckCircle2}
cls="border-green-500/30 bg-green-500/5" />
</div>
{(totalFailed > 0 || totalWarning > 0 || totalRunning > 0) && (
<div className="rounded-lg border p-4 space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Attention Required</p>
{totalFailed > 0 && (
<div className="flex items-center gap-2 text-sm text-red-600">
<XCircle className="w-4 h-4" />
{totalFailed} job{totalFailed !== 1 ? 's' : ''} failed
</div>
)}
{totalWarning > 0 && (
<div className="flex items-center gap-2 text-sm text-yellow-700">
<AlertTriangle className="w-4 h-4" />
{totalWarning} job{totalWarning !== 1 ? 's' : ''} completed with warnings
</div>
)}
{totalRunning > 0 && (
<div className="flex items-center gap-2 text-sm text-blue-600">
<Loader2 className="w-4 h-4 animate-spin" />
{totalRunning} job{totalRunning !== 1 ? 's' : ''} running (stalled jobs appear here)
</div>
)}
</div>
)}
</div>
);
}
function DattoRmmTab({ data, onSync, syncing }: { data: any; onSync: () => void; syncing: boolean }) {
if (!data) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
const unlinked = Math.max(0, (data.totalConfigItems ?? 0) - (data.rmmLinkedDevices ?? 0));
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<StatusDot ok={data.configured} />
<div>
<p className="text-sm font-medium">{data.configured ? 'Connected' : 'Not configured'}</p>
<p className="text-xs text-muted-foreground">{data.apiUrl}</p>
</div>
</div>
<div className="flex gap-2">
<a href="https://concord.rmm.datto.com" target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm">Open Portal</Button>
</a>
<Button size="sm" onClick={onSync} disabled={syncing || !data.configured}>
{syncing ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Sync CIs
</Button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<StatCard label="Active Config Items" value={data.totalConfigItems ?? 0} icon={Cpu} />
<StatCard label="RMM-Linked Devices" value={data.rmmLinkedDevices ?? 0} icon={Monitor}
sub="with rmm_device_uid" />
<StatCard label="Unlinked Devices" value={unlinked} icon={Monitor}
cls={unlinked > 0 ? 'border-yellow-500/30 bg-yellow-500/5' : ''} />
</div>
<div className="rounded-lg border p-4 bg-muted/30 text-sm text-muted-foreground">
Datto RMM device data is queried live via the RMM API when investigating alerts.
Device records link to Autotask Configuration Items via <code className="text-xs bg-muted px-1 rounded">rmm_device_uid</code>.
Run an Autotask Configuration Items sync to refresh CI data.
</div>
</div>
);
}
function AuvikTab({ data }: { data: any }) {
if (!data) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<StatusDot ok={data.configured} />
<div>
<p className="text-sm font-medium">{data.configured ? 'Connected' : 'Not configured'}</p>
<p className="text-xs text-muted-foreground">{data.apiUrl}</p>
</div>
</div>
<a href="https://auvikapi.us5.my.auvik.com" target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm">Open Portal</Button>
</a>
</div>
<div className="rounded-lg border p-4 bg-muted/30 text-sm text-muted-foreground">
Auvik provides network topology and device data. The API is configured and accessible.
Full sync and dashboard integration is planned data is currently available via the Auvik API endpoints
at <code className="text-xs bg-muted px-1 rounded">/api/auvik/devices</code> and <code className="text-xs bg-muted px-1 rounded">/api/auvik/tenant-mappings</code>.
</div>
</div>
);
}
function AddigyTab({ data }: { data: any }) {
if (!data) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<StatusDot ok={data.configured} />
<div>
<p className="text-sm font-medium">{data.configured ? 'Connected' : 'Not configured'}</p>
<p className="text-xs text-muted-foreground">{data.apiUrl}</p>
</div>
</div>
<a href="https://app.addigy.com" target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm">Open Portal</Button>
</a>
</div>
<div className="rounded-lg border p-4 bg-muted/30 text-sm text-muted-foreground">
Addigy manages Apple (macOS/iOS) devices. The API is configured and accessible via token auth.
Device and policy data is available via <code className="text-xs bg-muted px-1 rounded">/api/addigy-devices</code> and <code className="text-xs bg-muted px-1 rounded">/api/addigy-policies</code>.
Full sync integration is planned.
</div>
</div>
);
}
export default function IntegrationStatusTabs() {
const [status, setStatus] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [veeamSyncing, setVeeamSyncing] = useState(false);
const [rmmSyncing, setRmmSyncing] = useState(false);
const fetchStatus = async () => {
try {
const res = await fetch('/api/integrations/status');
if (res.ok) setStatus(await res.json());
} catch (e) {
console.error('Failed to fetch integration status:', e);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchStatus(); }, []);
const handleVeeamSync = async () => {
setVeeamSyncing(true);
try {
await fetch('/api/veeam/sync', { method: 'POST', body: JSON.stringify({ syncType: 'full' }), headers: { 'Content-Type': 'application/json' } });
// Poll until done
const poll = setInterval(async () => {
const r = await fetch('/api/veeam/sync');
if (r.ok) {
const d = await r.json();
if (!d.isSyncing) {
clearInterval(poll);
setVeeamSyncing(false);
fetchStatus();
}
}
}, 3000);
} catch {
setVeeamSyncing(false);
}
};
const handleRmmSync = async () => {
setRmmSyncing(true);
try {
await fetch('/api/sync/entity', {
method: 'POST',
body: JSON.stringify({ entities: ['configuration_items'] }),
headers: { 'Content-Type': 'application/json' },
});
setTimeout(() => { setRmmSyncing(false); fetchStatus(); }, 5000);
} catch {
setRmmSyncing(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
);
}
return (
<Tabs defaultValue="veeam" className="w-full">
<TabsList className="grid w-full max-w-lg grid-cols-4">
<TabsTrigger value="veeam" className="gap-1.5">
<Shield className="w-3.5 h-3.5" />
Veeam
</TabsTrigger>
<TabsTrigger value="datto" className="gap-1.5">
<Monitor className="w-3.5 h-3.5" />
Datto RMM
</TabsTrigger>
<TabsTrigger value="auvik" className="gap-1.5">
<Network className="w-3.5 h-3.5" />
Auvik
</TabsTrigger>
<TabsTrigger value="addigy" className="gap-1.5">
<Apple className="w-3.5 h-3.5" />
Addigy
</TabsTrigger>
</TabsList>
<TabsContent value="veeam" className="mt-6">
<VeeamTab data={status?.veeam} onSync={handleVeeamSync} syncing={veeamSyncing} />
</TabsContent>
<TabsContent value="datto" className="mt-6">
<DattoRmmTab data={status?.dattoRmm} onSync={handleRmmSync} syncing={rmmSyncing} />
</TabsContent>
<TabsContent value="auvik" className="mt-6">
<AuvikTab data={status?.auvik} />
</TabsContent>
<TabsContent value="addigy" className="mt-6">
<AddigyTab data={status?.addigy} />
</TabsContent>
</Tabs>
);
}

View file

@ -13,7 +13,8 @@ import {
RefreshCw,
ChevronDown,
Activity,
HardDrive
HardDrive,
Workflow,
} from 'lucide-react';
import {
NavigationMenu,
@ -59,10 +60,10 @@ const navigationItems: NavItem[] = [
icon: Activity,
children: [
{
title: 'Sync Management',
title: 'Integrations & Sync',
href: '/admin/sync',
icon: RefreshCw,
description: 'Sync data from external systems'
description: 'Manage sync across PSA, RMM, NMS, Backup, and Apple RMM'
},
{
title: 'NMS Mapping (Auvik)',
@ -82,6 +83,12 @@ const navigationItems: NavItem[] = [
icon: Smartphone,
description: 'Map Addigy devices to companies'
},
{
title: 'Workflow Engine',
href: '/admin/workflow',
icon: Workflow,
description: 'Automated ticket triage and classification'
},
{
title: 'Data Browser',
href: '/admin/data-browser',

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

File diff suppressed because one or more lines are too long

View file

@ -46,6 +46,17 @@ services:
restart: unless-stopped
ports:
- "3100:3100"
labels:
- "traefik.enable=true"
- "traefik.http.routers.pulse.rule=Host(`pulse.wulfconsulting.cloud`)"
- "traefik.http.routers.pulse.entrypoints=websecure"
- "traefik.http.routers.pulse.tls=true"
- "traefik.http.routers.pulse.tls.certresolver=cloudflare"
- "traefik.http.services.pulse.loadbalancer.server.port=3100"
- "traefik.docker.network=frontend"
networks:
- default
- frontend
env_file:
- .env.local
environment:
@ -85,6 +96,10 @@ services:
VEEAM_VSPC_URL: ${VEEAM_VSPC_URL}
VEEAM_VSPC_API_KEY: ${VEEAM_VSPC_API_KEY}
# Webhook Configuration
WEBHOOK_BASE_URL: ${WEBHOOK_BASE_URL:-https://pulse.wulfconsulting.cloud}
AUTOTASK_WEBHOOK_SECRET: ${AUTOTASK_WEBHOOK_SECRET}
# PostgreSQL Configuration
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
@ -110,3 +125,5 @@ volumes:
networks:
default:
name: pulse-network
frontend:
external: true

View file

@ -0,0 +1,359 @@
/**
* AI Triage Service
* Handles AI-powered classification for ambiguous tickets and text enhancement.
* Only called when robotic classifier can't confidently classify, or when
* title/description cleanup is needed.
*/
import { postgresClient } from './postgres-client';
import {
AiEnhancementResult,
AiPromptTemplate,
TicketData,
WorkflowSettings,
ClassificationResult,
PromptPurpose,
} from '../types/workflow';
export class AiTriageService {
/**
* Call the configured AI provider with a prompt.
*/
private async callProvider(
systemPrompt: string,
userPrompt: string,
settings: WorkflowSettings,
templateOverrides?: { provider?: string; model?: string; temperature?: number; max_tokens?: number }
): Promise<string> {
const provider = templateOverrides?.provider || settings.default_ai_provider;
const temperature = templateOverrides?.temperature ?? 0.3;
const maxTokens = templateOverrides?.max_tokens ?? 4000;
if (provider === 'anthropic') {
return this.callAnthropic(
systemPrompt,
userPrompt,
templateOverrides?.model || settings.anthropic_model,
settings.anthropic_api_key,
temperature,
maxTokens
);
} else {
return this.callOpenAI(
systemPrompt,
userPrompt,
templateOverrides?.model || settings.openai_model,
settings.openai_api_key,
temperature,
maxTokens
);
}
}
/**
* Call OpenAI API.
*/
private async callOpenAI(
systemPrompt: string,
userPrompt: string,
model: string,
apiKey: string,
temperature: number,
maxTokens: number
): Promise<string> {
if (!apiKey) throw new Error('OpenAI API key not configured');
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature,
max_tokens: maxTokens,
response_format: { type: 'json_object' },
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`OpenAI API error (${response.status}): ${error}`);
}
const data = await response.json();
return data.choices[0]?.message?.content || '';
}
/**
* Call Anthropic API.
*/
private async callAnthropic(
systemPrompt: string,
userPrompt: string,
model: string,
apiKey: string,
temperature: number,
maxTokens: number
): Promise<string> {
if (!apiKey) throw new Error('Anthropic API key not configured');
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model,
max_tokens: maxTokens,
temperature,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }],
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Anthropic API error (${response.status}): ${error}`);
}
const data = await response.json();
const textBlock = data.content?.find((b: any) => b.type === 'text');
return textBlock?.text || '';
}
/**
* Load an active AI prompt template by purpose.
*/
async getTemplate(purpose: PromptPurpose): Promise<AiPromptTemplate | null> {
const result = await postgresClient.query<AiPromptTemplate>(
`SELECT * FROM ai_prompt_templates WHERE purpose = $1 AND is_active = true ORDER BY version DESC LIMIT 1`,
[purpose]
);
return result.rows[0] || null;
}
/**
* Interpolate template variables in a prompt string.
* Supports {{field}} syntax.
*/
private interpolate(template: string, vars: Record<string, any>): string {
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
const val = vars[key];
return val !== undefined && val !== null ? String(val) : '';
});
}
/**
* Classify ambiguous fields that the robotic classifier couldn't handle.
* Returns only the fields that AI classified.
*/
async classifyAmbiguous(
ticket: TicketData,
failedFields: string[],
settings: WorkflowSettings
): Promise<AiEnhancementResult> {
const template = await this.getTemplate('ambiguous_classification');
// Load picklist data for AI context
const [issueTypes, subIssueTypes, priorities] = await Promise.all([
postgresClient.query(`SELECT value, label FROM issue_types WHERE is_deleted = false ORDER BY label`),
postgresClient.query(`SELECT value, label, parent_value FROM sub_issue_types WHERE is_deleted = false ORDER BY label`),
postgresClient.query(`SELECT value, label FROM priorities WHERE is_active = true ORDER BY value`),
]);
const picklistContext = {
issueTypes: JSON.stringify(issueTypes.rows),
subIssueTypes: JSON.stringify(subIssueTypes.rows),
priorities: JSON.stringify(priorities.rows),
};
const systemPrompt = template?.system_prompt || this.getDefaultClassificationSystemPrompt();
const userTemplate = template?.user_prompt_template || this.getDefaultClassificationUserPrompt();
const userPrompt = this.interpolate(userTemplate, {
title: ticket.title,
description: ticket.description || 'No description provided',
ticket_category: ticket.ticket_category,
failed_fields: failedFields.join(', '),
...picklistContext,
});
const aiResponse = await this.callProvider(systemPrompt, userPrompt, settings, {
provider: template?.provider,
model: template?.model,
temperature: template?.temperature,
max_tokens: template?.max_tokens,
});
try {
const parsed = JSON.parse(aiResponse);
return {
classification: {
issue_type: parsed.issueType || parsed.issue_type,
sub_issue_type: parsed.subIssueType || parsed.sub_issue_type,
ticket_type: parsed.ticketType || parsed.ticket_type,
priority: parsed.priority,
},
method: 'ai',
};
} catch {
console.error('[AI-TRIAGE] Failed to parse AI classification response:', aiResponse);
return { method: 'ai' };
}
}
/**
* Clean up a messy ticket title (email subject, too long, garbled).
*/
async cleanupTitle(
ticket: TicketData,
settings: WorkflowSettings
): Promise<string | undefined> {
if (!settings.ai_for_title_cleanup) return undefined;
const template = await this.getTemplate('title_cleanup');
const systemPrompt = template?.system_prompt ||
'You are a helpdesk ticket title cleaner. Given a ticket title, produce a clean, concise title (max 80 chars). Remove email prefixes (Re:, Fw:, Fwd:), ticket numbers, excessive punctuation, and redundant info. Return JSON: {"title": "cleaned title"}';
const userTemplate = template?.user_prompt_template ||
'Clean up this ticket title:\n\nOriginal title: {{title}}\nDescription (for context): {{description}}';
const userPrompt = this.interpolate(userTemplate, {
title: ticket.title,
description: (ticket.description || '').substring(0, 500),
});
const aiResponse = await this.callProvider(systemPrompt, userPrompt, settings, {
provider: template?.provider,
model: template?.model,
temperature: template?.temperature ?? 0.2,
max_tokens: template?.max_tokens ?? 200,
});
try {
const parsed = JSON.parse(aiResponse);
return parsed.title || undefined;
} catch {
console.error('[AI-TRIAGE] Failed to parse title cleanup response:', aiResponse);
return undefined;
}
}
/**
* Rewrite/restructure a ticket description.
*/
async rewriteDescription(
ticket: TicketData,
branch: string,
settings: WorkflowSettings
): Promise<string | undefined> {
if (!settings.ai_for_description_rewrite) return undefined;
if (!ticket.description || ticket.description.length < 50) return undefined;
const purpose: PromptPurpose = branch === 'noc' ? 'noc_format' : 'description_rewrite';
const template = await this.getTemplate(purpose);
if (!template) return undefined;
const userPrompt = this.interpolate(template.user_prompt_template, {
title: ticket.title,
description: ticket.description,
branch,
});
const aiResponse = await this.callProvider(template.system_prompt, userPrompt, settings, {
provider: template.provider,
model: template.model,
temperature: template.temperature,
max_tokens: template.max_tokens,
});
try {
const parsed = JSON.parse(aiResponse);
return parsed.description || undefined;
} catch {
// If not JSON, return raw text as description
return aiResponse.trim() || undefined;
}
}
/**
* Generate troubleshooting steps for an incident ticket.
*/
async generateTroubleshootingSteps(
ticket: TicketData,
settings: WorkflowSettings
): Promise<string | undefined> {
if (!settings.ai_for_troubleshooting) return undefined;
const template = await this.getTemplate('troubleshooting_steps');
const systemPrompt = template?.system_prompt ||
'You are an IT helpdesk assistant. Given a support ticket, generate 3-5 concise troubleshooting steps. Return JSON: {"steps": "numbered list of steps"}';
const userTemplate = template?.user_prompt_template ||
'Generate troubleshooting steps for this ticket:\n\nTitle: {{title}}\nDescription: {{description}}';
const userPrompt = this.interpolate(userTemplate, {
title: ticket.title,
description: (ticket.description || '').substring(0, 2000),
});
const aiResponse = await this.callProvider(systemPrompt, userPrompt, settings, {
provider: template?.provider,
model: template?.model,
temperature: template?.temperature ?? 0.3,
max_tokens: template?.max_tokens ?? 1000,
});
try {
const parsed = JSON.parse(aiResponse);
return parsed.steps || undefined;
} catch {
return aiResponse.trim() || undefined;
}
}
// ============================================================================
// Default prompts (used when no template is configured in DB)
// ============================================================================
private getDefaultClassificationSystemPrompt(): string {
return `You are an IT helpdesk ticket classifier. Given a ticket title and description, classify the ticket into the correct issue type, sub-issue type, ticket type, and priority.
Rules:
- ticket_type: 1 = Service Request, 2 = Incident
- Use the provided picklist data to select valid issue types and sub-issue types
- Ensure sub_issue_type parent_value matches the issue_type value
- Return ONLY valid picklist values
Return JSON format:
{"issueType": <number>, "subIssueType": <number>, "ticketType": <number>, "priority": <number>}`;
}
private getDefaultClassificationUserPrompt(): string {
return `Classify this ticket. Only provide values for these fields that need classification: {{failed_fields}}
Title: {{title}}
Description: {{description}}
Ticket Category: {{ticket_category}}
Available Issue Types:
{{issueTypes}}
Available Sub-Issue Types (with parent_value):
{{subIssueTypes}}
Available Priorities:
{{priorities}}`;
}
}
// Export singleton instance
export const aiTriageService = new AiTriageService();

View file

@ -0,0 +1,358 @@
/**
* Autotask Webhook Manager
* Registers, manages, and deregisters webhooks with the Autotask REST API.
* Autotask webhooks must be created via API there is no GUI-based creation.
*
* Supported entities: Companies, Contacts, ConfigurationItems, Tickets, TicketNotes
*/
import { AutotaskClient } from './autotask-client';
import { postgresClient } from './postgres-client';
import {
WebhookEntityType,
WEBHOOK_SUPPORTED_ENTITIES,
AutotaskWebhookRegistration,
AutotaskWebhookRegistrationResult,
} from '../types/webhook';
/**
* Maps our WebhookEntityType enum to the Autotask REST API webhook entity name.
* e.g. "Tickets" "TicketWebhooks"
*/
function getWebhookEntityName(entityType: WebhookEntityType): string {
const map: Record<string, string> = {
Companies: 'CompanyWebhooks',
Contacts: 'ContactWebhooks',
ConfigurationItems: 'ConfigurationItemWebhooks',
Tickets: 'TicketWebhooks',
TicketNotes: 'TicketNoteWebhooks',
};
return map[entityType] || `${entityType}Webhooks`;
}
export class AutotaskWebhookManager {
private client: AutotaskClient;
private baseUrl: string;
private webhookBaseUrl: string;
private secretKey: string;
private notificationEmail: string;
constructor() {
this.client = new AutotaskClient({
apiUrl: process.env.AUTOTASK_API_URL || '',
username: process.env.AUTOTASK_USERNAME || '',
password: process.env.AUTOTASK_SECRET || '',
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
});
this.baseUrl = process.env.AUTOTASK_API_URL || '';
this.webhookBaseUrl = process.env.WEBHOOK_BASE_URL || '';
this.secretKey = process.env.AUTOTASK_WEBHOOK_SECRET || '';
this.notificationEmail = process.env.AUTOTASK_USERNAME?.replace('@', '+webhooks@') || '';
}
/**
* Register webhooks for all supported entities
*/
async registerAll(): Promise<AutotaskWebhookRegistrationResult[]> {
const results: AutotaskWebhookRegistrationResult[] = [];
for (const entityType of WEBHOOK_SUPPORTED_ENTITIES) {
try {
const result = await this.registerWebhook(entityType);
results.push(result);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.error(`[WEBHOOK-MGR] Failed to register ${entityType}:`, msg);
results.push({
entityType,
webhookId: 0,
fieldsRegistered: 0,
excludedResources: 0,
success: false,
error: msg,
});
}
}
return results;
}
/**
* Register a webhook for a single entity type
*/
async registerWebhook(entityType: WebhookEntityType): Promise<AutotaskWebhookRegistrationResult> {
const webhookEntityName = getWebhookEntityName(entityType);
console.log(`[WEBHOOK-MGR] Registering webhook for ${entityType} via ${webhookEntityName}`);
// 1. Check if we already have a webhook registered
const existing = await this.getExistingWebhookId(entityType);
if (existing) {
console.log(`[WEBHOOK-MGR] Webhook already registered for ${entityType} (ID: ${existing}), deleting first`);
await this.deleteWebhook(entityType, existing);
}
// 2. Create the webhook
const webhookUrl = `${this.webhookBaseUrl}/api/webhooks/autotask`;
const deactivationUrl = `${this.webhookBaseUrl}/api/webhooks/autotask?deactivated=true`;
const registration: AutotaskWebhookRegistration = {
IsActive: true,
DeactivationUrl: deactivationUrl,
IsSubscribedToCreateEvents: true,
IsSubscribedToUpdateEvents: true,
IsSubscribedToDeleteEvents: true,
Name: `Pulse - ${entityType}`,
SecretKey: this.secretKey,
SendThresholdExceededNotification: true,
WebhookUrl: webhookUrl,
NotificationEmailAddress: this.notificationEmail,
};
const createUrl = `${this.baseUrl}/${webhookEntityName}`;
const createResponse = await this.apiCall<{ itemId: number }>(createUrl, 'POST', registration);
const webhookId = createResponse.itemId;
console.log(`[WEBHOOK-MGR] Created webhook for ${entityType}, ID: ${webhookId}`);
// 3. Discover and add trigger fields
let fieldsRegistered = 0;
try {
fieldsRegistered = await this.addTriggerFields(webhookEntityName, webhookId);
} catch (error) {
console.warn(`[WEBHOOK-MGR] Could not add trigger fields for ${entityType}:`, error);
}
// 4. Exclude the API user resource to prevent infinite loops
let excludedResources = 0;
try {
excludedResources = await this.excludeApiResource(webhookEntityName, webhookId);
} catch (error) {
console.warn(`[WEBHOOK-MGR] Could not exclude API resource for ${entityType}:`, error);
}
// 5. Save webhook ID to database
await this.saveWebhookConfig(entityType, webhookId);
return {
entityType,
webhookId,
fieldsRegistered,
excludedResources,
success: true,
};
}
/**
* Discover available webhook fields and register them as triggers.
* The fields endpoint is {Entity}WebhookFields/entityInformation/fields
* (a separate entity, not a sub-path of the webhook).
*/
private async addTriggerFields(webhookEntityName: string, webhookId: number): Promise<number> {
// Derive the fields entity name: "TicketWebhooks" → "TicketWebhookFields"
const fieldsEntityName = webhookEntityName.replace('Webhooks', 'WebhookFields');
const fieldsUrl = `${this.baseUrl}/${fieldsEntityName}/entityInformation/fields`;
const fieldsResponse = await this.apiCall<{ fields: any[] }>(fieldsUrl, 'GET');
// Find the fieldID picklist to discover available trigger fields
const fieldIdField = fieldsResponse.fields?.find((f: any) => f.name === 'fieldID');
if (!fieldIdField?.picklistValues) {
console.warn(`[WEBHOOK-MGR] No picklist values found for ${fieldsEntityName}`);
return 0;
}
const activeFields = fieldIdField.picklistValues.filter((p: any) => p.isActive);
let registered = 0;
// Register each available field as both a trigger and display-always field
for (const field of activeFields) {
try {
const addFieldUrl = `${this.baseUrl}/${webhookEntityName}/${webhookId}/Fields`;
await this.apiCall(addFieldUrl, 'POST', {
FieldID: parseInt(field.value),
IsDisplayAlwaysField: true,
IsSubscribedField: true,
WebhookID: webhookId,
});
registered++;
} catch (error) {
// Some fields may not support being triggers — that's OK
const msg = error instanceof Error ? error.message : String(error);
if (!msg.includes('already exists')) {
console.debug(`[WEBHOOK-MGR] Could not add field ${field.label} (${field.value}): ${msg}`);
}
}
}
console.log(`[WEBHOOK-MGR] Registered ${registered}/${activeFields.length} trigger fields for webhook ${webhookId}`);
return registered;
}
/**
* Exclude the API integration resource from triggering webhooks (prevents infinite loops)
*/
private async excludeApiResource(webhookEntityName: string, webhookId: number): Promise<number> {
// Find the API resource by looking up the username
const apiUsername = process.env.AUTOTASK_USERNAME || '';
if (!apiUsername) return 0;
try {
const resources = await this.client.getFieldInfo('Resources');
// The API user is typically an API-only resource; we look it up by email
const resource = await this.client.getResourceByEmail(apiUsername.split('@')[0] + '@' + apiUsername.split('@')[1]?.replace('@', ''));
if (!resource) {
console.warn(`[WEBHOOK-MGR] Could not find API resource for ${apiUsername}`);
return 0;
}
const excludeUrl = `${this.baseUrl}/${webhookEntityName}/${webhookId}/ExcludedResources`;
await this.apiCall(excludeUrl, 'POST', {
ResourceID: resource.id,
WebhookID: webhookId,
});
console.log(`[WEBHOOK-MGR] Excluded resource ${resource.id} (${apiUsername}) from webhook ${webhookId}`);
return 1;
} catch (error) {
console.warn(`[WEBHOOK-MGR] Failed to exclude API resource:`, error);
return 0;
}
}
/**
* List all webhooks for an entity type
*/
async listWebhooks(entityType: WebhookEntityType): Promise<any[]> {
const webhookEntityName = getWebhookEntityName(entityType);
const url = `${this.baseUrl}/${webhookEntityName}/query`;
try {
const response = await this.apiCall<{ items: any[] }>(url, 'POST', {
filter: [{ op: 'gte', field: 'id', value: 0 }],
});
return response.items || [];
} catch (error) {
console.error(`[WEBHOOK-MGR] Failed to list webhooks for ${entityType}:`, error);
return [];
}
}
/**
* Delete a webhook from Autotask
*/
async deleteWebhook(entityType: WebhookEntityType, webhookId: number): Promise<void> {
const webhookEntityName = getWebhookEntityName(entityType);
const url = `${this.baseUrl}/${webhookEntityName}/${webhookId}`;
try {
await this.apiCall(url, 'DELETE');
console.log(`[WEBHOOK-MGR] Deleted webhook ${webhookId} for ${entityType}`);
} catch (error) {
console.warn(`[WEBHOOK-MGR] Failed to delete webhook ${webhookId}:`, error);
}
// Remove from database
await postgresClient.query(
`UPDATE webhook_configs SET autotask_webhook_id = NULL WHERE entity_type = $1`,
[entityType]
);
}
/**
* Deregister all webhooks
*/
async deregisterAll(): Promise<void> {
for (const entityType of WEBHOOK_SUPPORTED_ENTITIES) {
const webhookId = await this.getExistingWebhookId(entityType);
if (webhookId) {
await this.deleteWebhook(entityType, webhookId);
}
}
}
/**
* Get status of all registered webhooks
*/
async getStatus(): Promise<Array<{
entityType: WebhookEntityType;
webhookId: number | null;
isActive: boolean;
isConfigured: boolean;
}>> {
const results = [];
for (const entityType of WEBHOOK_SUPPORTED_ENTITIES) {
const config = await postgresClient.query<{
autotask_webhook_id: string | null;
is_active: boolean;
}>(
`SELECT autotask_webhook_id, is_active FROM webhook_configs WHERE entity_type = $1`,
[entityType]
);
const row = config.rows[0];
results.push({
entityType,
webhookId: row?.autotask_webhook_id ? parseInt(row.autotask_webhook_id) : null,
isActive: row?.is_active ?? false,
isConfigured: !!row?.autotask_webhook_id,
});
}
return results;
}
// --- Private helpers ---
private async getExistingWebhookId(entityType: WebhookEntityType): Promise<number | null> {
const result = await postgresClient.query<{ autotask_webhook_id: string }>(
`SELECT autotask_webhook_id FROM webhook_configs WHERE entity_type = $1 AND autotask_webhook_id IS NOT NULL`,
[entityType]
);
if (result.rows.length > 0 && result.rows[0].autotask_webhook_id) {
return parseInt(result.rows[0].autotask_webhook_id);
}
return null;
}
private async saveWebhookConfig(entityType: WebhookEntityType, webhookId: number): Promise<void> {
await postgresClient.query(
`INSERT INTO webhook_configs (entity_type, event_types, is_active, autotask_webhook_id)
VALUES ($1, $2, true, $3)
ON CONFLICT (entity_type) DO UPDATE SET
autotask_webhook_id = $3,
is_active = true,
updated_at = NOW()`,
[entityType, JSON.stringify(['create', 'update', 'delete']), String(webhookId)]
);
}
private async apiCall<T = any>(url: string, method: string, body?: any): Promise<T> {
const headers: Record<string, string> = {
'Username': process.env.AUTOTASK_USERNAME || '',
'Secret': process.env.AUTOTASK_SECRET || '',
'APIIntegrationcode': process.env.AUTOTASK_API_INTEGRATION_CODE || '',
'Content-Type': 'application/json',
'Accept': 'application/json',
};
const options: RequestInit = { method, headers };
if (body && method !== 'GET' && method !== 'DELETE') {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Autotask API ${method} ${url} failed (${response.status}): ${errorText}`);
}
// DELETE returns no body
if (method === 'DELETE') {
return {} as T;
}
return response.json();
}
}

View file

@ -128,6 +128,6 @@ export class DattoRMMClientSimple {
}
// Then get devices for that site
return this.getDevicesBySite(site.id);
return this.getDevicesBySite(site.uid);
}
}

View file

@ -2,6 +2,7 @@ import {
DattoRMMConfig,
DattoRMMDevice,
DattoRMMSite,
DattoRMMAlert,
DattoRMMApiResponse,
DattoRMMError,
} from '@/lib/types/datto-rmm';
@ -381,4 +382,88 @@ export class DattoRMMClient {
return [];
}
}
/**
* Generic paginated fetch follows nextPageUrl until exhausted
*/
private async fetchAllPages<T>(
endpoint: string,
dataKey: string,
pageSize = 250
): Promise<T[]> {
const allItems: T[] = [];
let url: string | null = `https://concord-api.centrastage.net/api/v2${endpoint}?pageSize=${pageSize}`;
while (url) {
const token = await this.getAccessToken();
const resp: Response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
if (!resp.ok) {
const errText = await resp.text();
throw new Error(`Datto RMM API ${resp.status}: ${errText.substring(0, 200)}`);
}
const body: any = await resp.json();
const items = body[dataKey] || [];
allItems.push(...items);
const nextUrl: string | null = body.pageDetails?.nextPageUrl ?? null;
console.log(`[DATTO-RMM] ${dataKey}: fetched ${allItems.length} (page had ${items.length})`);
url = nextUrl;
}
return allItems;
}
/**
* Get all sites (paginated)
*/
async getAllSites(): Promise<DattoRMMSite[]> {
return this.fetchAllPages<DattoRMMSite>('/account/sites', 'sites');
}
/**
* Get all open alerts (paginated)
*/
async getAllOpenAlerts(): Promise<DattoRMMAlert[]> {
return this.fetchAllPages<DattoRMMAlert>('/account/alerts/open', 'alerts');
}
/**
* Get all resolved alerts (paginated, recent only)
*/
async getRecentResolvedAlerts(maxPages = 4): Promise<DattoRMMAlert[]> {
const allAlerts: DattoRMMAlert[] = [];
let url: string | null = 'https://concord-api.centrastage.net/api/v2/account/alerts/resolved?pageSize=250';
let pages = 0;
while (url && pages < maxPages) {
const token = await this.getAccessToken();
const resp: Response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
if (!resp.ok) break;
const body: any = await resp.json();
const alerts = body.alerts || [];
allAlerts.push(...alerts);
url = body.pageDetails?.nextPageUrl ?? null;
pages++;
console.log(`[DATTO-RMM] resolved alerts: fetched ${allAlerts.length} (page ${pages})`);
}
return allAlerts;
}
}

View file

@ -0,0 +1,319 @@
/**
* Datto RMM Sync Service
* Syncs sites, devices, and alerts from Datto RMM API to PostgreSQL
*/
import postgresClient from './postgres-client';
import { DattoRMMClient } from './datto-rmm-client';
import { DattoRMMSite, DattoRMMDevice, DattoRMMAlert } from '@/lib/types/datto-rmm';
export interface DattoRMMSyncResult {
syncId: string;
syncType: 'full' | 'incremental';
status: 'completed' | 'failed';
startedAt: Date;
completedAt: Date;
duration: number;
entities: DattoRMMEntitySyncResult[];
errors: string[];
}
export interface DattoRMMEntitySyncResult {
entity: string;
success: boolean;
recordsUpserted: number;
duration: number;
error?: string;
}
export class DattoRMMSyncService {
private client: DattoRMMClient;
private isSyncing = false;
constructor(client?: DattoRMMClient) {
if (client) {
this.client = client;
} else {
this.client = new DattoRMMClient({
apiUrl: process.env.DATTO_RMM_API_URL || 'https://concord-api.centrastage.net',
apiKey: process.env.DATTO_RMM_API_KEY || '',
apiSecret: process.env.DATTO_RMM_API_SECRET || '',
});
}
}
isSyncInProgress(): boolean {
return this.isSyncing;
}
async fullSync(triggeredBy = 'system'): Promise<DattoRMMSyncResult> {
return this.executeSync('full', triggeredBy);
}
async incrementalSync(triggeredBy = 'system'): Promise<DattoRMMSyncResult> {
return this.executeSync('incremental', triggeredBy);
}
private async executeSync(syncType: 'full' | 'incremental', triggeredBy: string): Promise<DattoRMMSyncResult> {
if (this.isSyncing) {
throw new Error('A Datto RMM sync is already in progress');
}
this.isSyncing = true;
const syncId = `datto-rmm-${Date.now()}`;
const startTime = new Date();
const entityResults: DattoRMMEntitySyncResult[] = [];
const errors: string[] = [];
// Create sync history record
let historyId: number | null = null;
try {
const histResult = await postgresClient.query<{ id: number }>(
`INSERT INTO sync_history (entity_type, sync_type, status, started_at, records_added, records_updated, records_deleted, triggered_by)
VALUES ($1, $2, $3, $4, 0, 0, 0, $5) RETURNING id`,
['datto_rmm', syncType, 'started', startTime, triggeredBy]
);
historyId = histResult.rows[0].id;
} catch (e) {
console.warn('[DATTO-RMM-SYNC] Could not create sync history record:', e);
}
console.log(`[DATTO-RMM-SYNC] Starting ${syncType} sync (${syncId})`);
try {
const steps: Array<{ name: string; fn: () => Promise<number> }> = [
{ name: 'sites', fn: () => this.syncSites() },
{ name: 'devices', fn: () => this.syncDevices() },
{ name: 'open_alerts', fn: () => this.syncOpenAlerts() },
{ name: 'resolved_alerts', fn: () => this.syncResolvedAlerts() },
];
for (const step of steps) {
const stepStart = Date.now();
try {
const count = await step.fn();
const duration = Date.now() - stepStart;
entityResults.push({ entity: step.name, success: true, recordsUpserted: count, duration });
console.log(`[DATTO-RMM-SYNC] ${step.name}: ${count} records in ${duration}ms`);
} catch (error) {
const duration = Date.now() - stepStart;
const msg = error instanceof Error ? error.message : String(error);
errors.push(`${step.name}: ${msg}`);
entityResults.push({ entity: step.name, success: false, recordsUpserted: 0, duration, error: msg });
console.error(`[DATTO-RMM-SYNC] ${step.name} failed:`, msg);
}
}
const completedAt = new Date();
const duration = completedAt.getTime() - startTime.getTime();
const status = errors.length === 0 ? 'completed' : 'failed';
const totalRecords = entityResults.reduce((sum, r) => sum + r.recordsUpserted, 0);
console.log(`[DATTO-RMM-SYNC] Sync ${status} in ${duration}ms — ${totalRecords} total records`);
if (historyId) {
try {
await postgresClient.query(
`UPDATE sync_history SET status = $1, completed_at = $2, records_added = $3, error_message = $4, entity_details = $5 WHERE id = $6`,
[status, completedAt, totalRecords, errors.length > 0 ? errors.join('; ') : null, JSON.stringify(entityResults), historyId]
);
} catch (e) {
console.warn('[DATTO-RMM-SYNC] Could not update sync history:', e);
}
}
return { syncId, syncType, status, startedAt: startTime, completedAt, duration, entities: entityResults, errors };
} catch (error) {
const completedAt = new Date();
const msg = error instanceof Error ? error.message : String(error);
console.error('[DATTO-RMM-SYNC] Sync failed catastrophically:', msg);
if (historyId) {
try {
await postgresClient.query(
`UPDATE sync_history SET status = 'failed', completed_at = $1, error_message = $2 WHERE id = $3`,
[completedAt, msg, historyId]
);
} catch (e) { /* ignore */ }
}
return {
syncId, syncType, status: 'failed', startedAt: startTime, completedAt,
duration: completedAt.getTime() - startTime.getTime(), entities: entityResults, errors: [msg],
};
} finally {
this.isSyncing = false;
}
}
// ── Sites ─────────────────────────────────────────────────────────────────────
private async syncSites(): Promise<number> {
const sites = await this.client.getAllSites();
if (sites.length === 0) return 0;
// Build set of known company IDs for FK safety
const knownCompanies = await postgresClient.query('SELECT id FROM companies');
const companyIds = new Set(knownCompanies.rows.map((r: any) => r.id));
let count = 0;
for (const s of sites) {
const atCompanyId = s.autotaskCompanyId ? parseInt(s.autotaskCompanyId, 10) : null;
const matchedCompanyId = atCompanyId && !isNaN(atCompanyId) && atCompanyId > 0 && companyIds.has(atCompanyId)
? atCompanyId : null;
await postgresClient.query(
`INSERT INTO datto_rmm_sites (id, uid, account_uid, name, description, notes, on_demand,
autotask_company_id, autotask_company_name,
number_of_devices, number_of_online_devices, number_of_offline_devices,
portal_url, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,NOW())
ON CONFLICT (id) DO UPDATE SET
uid=EXCLUDED.uid, account_uid=EXCLUDED.account_uid, name=EXCLUDED.name,
description=EXCLUDED.description, notes=EXCLUDED.notes, on_demand=EXCLUDED.on_demand,
autotask_company_id=EXCLUDED.autotask_company_id, autotask_company_name=EXCLUDED.autotask_company_name,
number_of_devices=EXCLUDED.number_of_devices, number_of_online_devices=EXCLUDED.number_of_online_devices,
number_of_offline_devices=EXCLUDED.number_of_offline_devices,
portal_url=EXCLUDED.portal_url, synced_at=NOW(), updated_at=NOW()`,
[
s.id, s.uid, s.accountUid || null, s.name, s.description || null, s.notes || null, s.onDemand,
matchedCompanyId, s.autotaskCompanyName || null,
s.devicesStatus?.numberOfDevices ?? 0,
s.devicesStatus?.numberOfOnlineDevices ?? 0,
s.devicesStatus?.numberOfOfflineDevices ?? 0,
s.portalUrl || null,
]
);
count++;
}
return count;
}
// ── Devices ───────────────────────────────────────────────────────────────────
private async syncDevices(): Promise<number> {
const devices = await this.client.getAllDevices();
if (devices.length === 0) return 0;
// Build set of known site IDs for FK safety
const knownSites = await postgresClient.query('SELECT id FROM datto_rmm_sites');
const siteIds = new Set(knownSites.rows.map((r: any) => r.id));
let count = 0;
for (const d of devices) {
const siteId = siteIds.has(d.siteId) ? d.siteId : null;
await postgresClient.query(
`INSERT INTO datto_rmm_devices (id, uid, site_id, site_uid, site_name, hostname, description,
device_type_category, device_type, operating_system, domain,
int_ip_address, ext_ip_address, last_logged_in_user,
last_seen, last_reboot, last_audit_date, creation_date,
online, suspended, deleted, reboot_required, a64_bit,
cag_version, display_version,
antivirus_product, antivirus_status,
patch_status, patches_approved_pending, patches_not_approved, patches_installed,
software_status, portal_url, web_remote_url, warranty_date,
snmp_enabled, device_class, network_probe, udf, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,NOW())
ON CONFLICT (id) DO UPDATE SET
uid=EXCLUDED.uid, site_id=EXCLUDED.site_id, site_uid=EXCLUDED.site_uid, site_name=EXCLUDED.site_name,
hostname=EXCLUDED.hostname, description=EXCLUDED.description,
device_type_category=EXCLUDED.device_type_category, device_type=EXCLUDED.device_type,
operating_system=EXCLUDED.operating_system, domain=EXCLUDED.domain,
int_ip_address=EXCLUDED.int_ip_address, ext_ip_address=EXCLUDED.ext_ip_address,
last_logged_in_user=EXCLUDED.last_logged_in_user,
last_seen=EXCLUDED.last_seen, last_reboot=EXCLUDED.last_reboot,
last_audit_date=EXCLUDED.last_audit_date, creation_date=EXCLUDED.creation_date,
online=EXCLUDED.online, suspended=EXCLUDED.suspended, deleted=EXCLUDED.deleted,
reboot_required=EXCLUDED.reboot_required, a64_bit=EXCLUDED.a64_bit,
cag_version=EXCLUDED.cag_version, display_version=EXCLUDED.display_version,
antivirus_product=EXCLUDED.antivirus_product, antivirus_status=EXCLUDED.antivirus_status,
patch_status=EXCLUDED.patch_status, patches_approved_pending=EXCLUDED.patches_approved_pending,
patches_not_approved=EXCLUDED.patches_not_approved, patches_installed=EXCLUDED.patches_installed,
software_status=EXCLUDED.software_status, portal_url=EXCLUDED.portal_url,
web_remote_url=EXCLUDED.web_remote_url, warranty_date=EXCLUDED.warranty_date,
snmp_enabled=EXCLUDED.snmp_enabled, device_class=EXCLUDED.device_class,
network_probe=EXCLUDED.network_probe, udf=EXCLUDED.udf,
synced_at=NOW(), updated_at=NOW()`,
[
d.id, d.uid, siteId, d.siteUid, d.siteName, d.hostname, d.description || null,
d.deviceType?.category || null, d.deviceType?.type || null,
d.operatingSystem || null, d.domain || null,
d.intIpAddress || null, d.extIpAddress || null, d.lastLoggedInUser || null,
d.lastSeen ? new Date(d.lastSeen) : null,
d.lastReboot ? new Date(d.lastReboot) : null,
d.lastAuditDate ? new Date(d.lastAuditDate) : null,
d.creationDate ? new Date(d.creationDate) : null,
d.online, d.suspended, d.deleted, d.rebootRequired ?? false, d.a64Bit ?? true,
d.cagVersion || null, d.displayVersion || null,
d.antivirus?.antivirusProduct || null, d.antivirus?.antivirusStatus || null,
d.patchManagement?.patchStatus || null,
d.patchManagement?.patchesApprovedPending ?? 0,
d.patchManagement?.patchesNotApproved ?? 0,
d.patchManagement?.patchesInstalled ?? 0,
d.softwareStatus || null, d.portalUrl || null, d.webRemoteUrl || null,
d.warrantyDate ? new Date(d.warrantyDate) : null,
d.snmpEnabled ?? false, d.deviceClass || null, (d as any).networkProbe ?? false,
d.udf ? JSON.stringify(d.udf) : null,
]
);
count++;
}
return count;
}
// ── Alerts ────────────────────────────────────────────────────────────────────
private async upsertAlerts(alerts: DattoRMMAlert[]): Promise<number> {
let count = 0;
for (const a of alerts) {
await postgresClient.query(
`INSERT INTO datto_rmm_alerts (alert_uid, device_uid, device_name, site_uid, site_name,
priority, alert_context, alert_monitor_info, diagnostics,
resolved, resolved_by, resolved_on, muted, ticket_number,
autoresolve_mins, response_actions, timestamp, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW())
ON CONFLICT (alert_uid) DO UPDATE SET
device_uid=EXCLUDED.device_uid, device_name=EXCLUDED.device_name,
site_uid=EXCLUDED.site_uid, site_name=EXCLUDED.site_name,
priority=EXCLUDED.priority, alert_context=EXCLUDED.alert_context,
alert_monitor_info=EXCLUDED.alert_monitor_info, diagnostics=EXCLUDED.diagnostics,
resolved=EXCLUDED.resolved, resolved_by=EXCLUDED.resolved_by,
resolved_on=EXCLUDED.resolved_on, muted=EXCLUDED.muted,
ticket_number=EXCLUDED.ticket_number, autoresolve_mins=EXCLUDED.autoresolve_mins,
response_actions=EXCLUDED.response_actions,
synced_at=NOW(), updated_at=NOW()`,
[
a.alertUid,
a.alertSourceInfo?.deviceUid || null,
a.alertSourceInfo?.deviceName || null,
a.alertSourceInfo?.siteUid || null,
a.alertSourceInfo?.siteName || null,
a.priority || null,
a.alertContext ? JSON.stringify(a.alertContext) : null,
a.alertMonitorInfo ? JSON.stringify(a.alertMonitorInfo) : null,
a.diagnostics || null,
a.resolved,
a.resolvedBy || null,
a.resolvedOn ? new Date(a.resolvedOn) : null,
a.muted,
a.ticketNumber || null,
a.autoresolveMins ?? null,
a.responseActions ? JSON.stringify(a.responseActions) : null,
new Date(a.timestamp),
]
);
count++;
}
return count;
}
private async syncOpenAlerts(): Promise<number> {
const alerts = await this.client.getAllOpenAlerts();
if (alerts.length === 0) return 0;
return this.upsertAlerts(alerts);
}
private async syncResolvedAlerts(): Promise<number> {
const alerts = await this.client.getRecentResolvedAlerts(4);
if (alerts.length === 0) return 0;
return this.upsertAlerts(alerts);
}
}

View file

@ -71,6 +71,15 @@ export class EntitySyncService {
if (entity === EntityType.SUB_ISSUE_TYPES) {
return await this.syncSubIssueTypes(isIncremental);
}
if (entity === EntityType.QUEUES) {
return await this.syncQueues(isIncremental);
}
if (entity === EntityType.PRIORITIES) {
return await this.syncPriorities(isIncremental);
}
if (entity === EntityType.TICKET_CATEGORIES) {
return await this.syncTicketCategories(isIncremental);
}
const trackingId = syncId || `${entity}_${Date.now()}`;
const entityLogger = this.logger.child({ syncId: trackingId, entityType: entity });
@ -889,6 +898,141 @@ export class EntitySyncService {
}
}
/**
* Sync Queues (Picklist from Ticket field)
*/
async syncQueues(isIncremental: boolean = false): Promise<EntitySyncStats> {
const picklistLogger = this.logger.child({ entityType: EntityType.QUEUES, syncType: 'picklist' });
const syncStartTime = picklistLogger.start('Picklist sync');
try {
const picklistValues = await this.autotaskClient.getPicklistValues('Tickets', 'queueID');
const records = Object.entries(picklistValues).map(([value, label]) => ({
value: parseInt(value),
label: label,
is_active: true,
sort_order: parseInt(value),
synced_at: new Date(),
}));
picklistLogger.info('Found picklist values', { recordCount: records.length });
const tableName = getTableName(EntityType.QUEUES);
const existingQuery = `SELECT value FROM ${tableName}`;
const existingResult = await postgresClient.query<{ value: number }>(existingQuery);
const existingValues = new Set(existingResult.rows.map((r: any) => r.value));
const recordsAdded = records.filter((r: any) => !existingValues.has(r.value)).length;
const recordsUpdated = records.filter((r: any) => existingValues.has(r.value)).length;
await postgresClient.bulkUpsert(tableName, records, ['value']);
const stats: EntitySyncStats = { recordsAdded, recordsUpdated, recordsDeleted: 0 };
picklistLogger.complete('Picklist sync', syncStartTime, {
recordsAdded: stats.recordsAdded,
recordsUpdated: stats.recordsUpdated,
});
return stats;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
picklistLogger.fail('Picklist sync', syncStartTime, err);
throw error;
}
}
/**
* Sync Priorities (Picklist from Ticket field)
*/
async syncPriorities(isIncremental: boolean = false): Promise<EntitySyncStats> {
const picklistLogger = this.logger.child({ entityType: EntityType.PRIORITIES, syncType: 'picklist' });
const syncStartTime = picklistLogger.start('Picklist sync');
try {
const picklistValues = await this.autotaskClient.getPicklistValues('Tickets', 'priority');
const records = Object.entries(picklistValues).map(([value, label]) => ({
value: parseInt(value),
label: label,
is_active: true,
sort_order: parseInt(value),
synced_at: new Date(),
}));
picklistLogger.info('Found picklist values', { recordCount: records.length });
const tableName = getTableName(EntityType.PRIORITIES);
const existingQuery = `SELECT value FROM ${tableName}`;
const existingResult = await postgresClient.query<{ value: number }>(existingQuery);
const existingValues = new Set(existingResult.rows.map((r: any) => r.value));
const recordsAdded = records.filter((r: any) => !existingValues.has(r.value)).length;
const recordsUpdated = records.filter((r: any) => existingValues.has(r.value)).length;
await postgresClient.bulkUpsert(tableName, records, ['value']);
const stats: EntitySyncStats = { recordsAdded, recordsUpdated, recordsDeleted: 0 };
picklistLogger.complete('Picklist sync', syncStartTime, {
recordsAdded: stats.recordsAdded,
recordsUpdated: stats.recordsUpdated,
});
return stats;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
picklistLogger.fail('Picklist sync', syncStartTime, err);
throw error;
}
}
/**
* Sync Ticket Categories (Picklist from Ticket field)
*/
async syncTicketCategories(isIncremental: boolean = false): Promise<EntitySyncStats> {
const picklistLogger = this.logger.child({ entityType: EntityType.TICKET_CATEGORIES, syncType: 'picklist' });
const syncStartTime = picklistLogger.start('Picklist sync');
try {
const picklistValues = await this.autotaskClient.getPicklistValues('Tickets', 'ticketCategory');
const records = Object.entries(picklistValues).map(([value, label]) => ({
value: parseInt(value),
label: label,
is_active: true,
sort_order: parseInt(value),
synced_at: new Date(),
}));
picklistLogger.info('Found picklist values', { recordCount: records.length });
const tableName = getTableName(EntityType.TICKET_CATEGORIES);
const existingQuery = `SELECT value FROM ${tableName}`;
const existingResult = await postgresClient.query<{ value: number }>(existingQuery);
const existingValues = new Set(existingResult.rows.map((r: any) => r.value));
const recordsAdded = records.filter((r: any) => !existingValues.has(r.value)).length;
const recordsUpdated = records.filter((r: any) => existingValues.has(r.value)).length;
await postgresClient.bulkUpsert(tableName, records, ['value']);
const stats: EntitySyncStats = { recordsAdded, recordsUpdated, recordsDeleted: 0 };
picklistLogger.complete('Picklist sync', syncStartTime, {
recordsAdded: stats.recordsAdded,
recordsUpdated: stats.recordsUpdated,
});
return stats;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
picklistLogger.fail('Picklist sync', syncStartTime, err);
throw error;
}
}
/**
* Sync Work Types (Picklist from TimeEntry field)
*/

View file

@ -0,0 +1,373 @@
/**
* Robotic Classifier
* DB-driven keyword classification engine for ticket triage.
* Handles ~90% of classification deterministically without AI.
*/
import { postgresClient } from './postgres-client';
import {
ClassificationRule,
ClassificationResult,
ClassificationStepResult,
ConfidenceLevel,
RuleType,
TicketData,
} from '../types/workflow';
// Cache TTL for classification rules (5 minutes)
const RULES_CACHE_TTL_MS = 5 * 60 * 1000;
export class RoboticClassifier {
private rulesCache: Map<RuleType, ClassificationRule[]> = new Map();
private cacheLoadedAt: number = 0;
/**
* Load classification rules from DB, grouped by rule_type.
* Cached for RULES_CACHE_TTL_MS to avoid repeated DB queries.
*/
async loadRules(forceRefresh = false): Promise<Map<RuleType, ClassificationRule[]>> {
const now = Date.now();
if (!forceRefresh && this.rulesCache.size > 0 && now - this.cacheLoadedAt < RULES_CACHE_TTL_MS) {
return this.rulesCache;
}
const result = await postgresClient.query<ClassificationRule>(
`SELECT * FROM classification_rules WHERE is_active = true ORDER BY rule_type, sort_order`
);
this.rulesCache.clear();
for (const rule of result.rows) {
const existing = this.rulesCache.get(rule.rule_type) || [];
existing.push(rule);
this.rulesCache.set(rule.rule_type, existing);
}
this.cacheLoadedAt = now;
console.log(`[CLASSIFIER] Loaded ${result.rows.length} classification rules`);
return this.rulesCache;
}
/**
* Run full classification pipeline on a ticket.
*/
async classify(ticket: TicketData): Promise<ClassificationResult> {
await this.loadRules();
const branch = await this.classifyByType('branch_routing', ticket);
const ticketType = await this.classifyByType('ticket_type', ticket);
const issueClassification = await this.classifyByType('issue_classification', ticket);
// Priority classification can use ticket_type result as context
const priorityTicket = { ...ticket };
if (ticketType?.value != null) {
priorityTicket.ticket_type = ticketType.value;
}
const priority = await this.classifyByType('priority', priorityTicket);
// Queue routing can use priority result as context
const queueTicket = { ...priorityTicket };
if (priority?.value != null) {
queueTicket.priority = priority.value;
}
const queue = await this.classifyByType('queue_routing', queueTicket);
// Determine overall confidence and whether AI is needed
const stepResults = [branch, ticketType, issueClassification, priority, queue];
const { overallConfidence, needsAi, aiReasons } = this.assessConfidence(
stepResults,
ticket
);
return {
branch: branch || this.defaultBranch(),
ticket_type: ticketType,
issue_classification: issueClassification,
priority: priority,
queue: queue,
overall_confidence: overallConfidence,
needs_ai: needsAi,
ai_reasons: aiReasons,
};
}
/**
* Run classification for a specific rule type.
*/
private async classifyByType(
ruleType: RuleType,
ticket: TicketData
): Promise<ClassificationStepResult | null> {
const rules = this.rulesCache.get(ruleType) || [];
for (const rule of rules) {
if (this.evaluateRule(rule, ticket)) {
return {
field: rule.result_field,
value: rule.result_value,
field_2: rule.result_field_2 || undefined,
value_2: rule.result_value_2 || undefined,
confidence: rule.confidence,
matched_rule_id: rule.id,
matched_rule_name: rule.name,
method: 'robotic',
};
}
}
return null;
}
/**
* Evaluate a single classification rule against ticket data.
*/
private evaluateRule(rule: ClassificationRule, ticket: TicketData): boolean {
const fieldValue = this.getFieldValue(rule.match_field, ticket);
if (fieldValue === null || fieldValue === undefined) {
return false;
}
return this.evaluateMatch(
rule.match_operator,
fieldValue,
rule.match_value,
rule.match_case_sensitive
);
}
/**
* Get the value of a field from the ticket data.
* For 'title_or_description', returns a combined check target.
*/
private getFieldValue(
matchField: string,
ticket: TicketData
): string | number | null {
switch (matchField) {
case 'title':
return ticket.title || null;
case 'description':
return ticket.description || null;
case 'title_or_description':
// Return combined text for pattern matching
return [ticket.title, ticket.description].filter(Boolean).join(' ') || null;
case 'ticket_category':
return ticket.ticket_category;
case 'ticket_type':
return ticket.ticket_type;
case 'priority':
return ticket.priority;
case 'policy_name':
return ticket.policy_name || null;
case 'device_name':
return ticket.device_name || null;
case 'creator_resource_id':
return ticket.creator_resource_id;
case 'person_id':
return ticket.person_id;
case 'company_id':
return ticket.company_id;
default:
return null;
}
}
/**
* Evaluate a match operation.
*/
private evaluateMatch(
operator: string,
fieldValue: string | number,
matchValue: any,
caseSensitive: boolean
): boolean {
switch (operator) {
case 'contains':
return this.evaluateContains(fieldValue, matchValue, caseSensitive);
case 'starts_with':
return this.evaluateStartsWith(fieldValue, matchValue, caseSensitive);
case 'regex':
return this.evaluateRegex(fieldValue, matchValue, caseSensitive);
case 'equals':
return this.evaluateEquals(fieldValue, matchValue);
case 'in':
return this.evaluateIn(fieldValue, matchValue);
case 'not_in':
return !this.evaluateIn(fieldValue, matchValue);
default:
return false;
}
}
/**
* Contains: check if field contains any of the match values.
* matchValue can be a string or array of strings.
*/
private evaluateContains(
fieldValue: string | number,
matchValue: any,
caseSensitive: boolean
): boolean {
const text = String(fieldValue);
const searchText = caseSensitive ? text : text.toLowerCase();
const patterns = Array.isArray(matchValue) ? matchValue : [matchValue];
return patterns.some((pattern: string) => {
const searchPattern = caseSensitive ? String(pattern) : String(pattern).toLowerCase();
return searchText.includes(searchPattern);
});
}
/**
* Starts with: check if field starts with the match value.
*/
private evaluateStartsWith(
fieldValue: string | number,
matchValue: any,
caseSensitive: boolean
): boolean {
const text = String(fieldValue);
const searchText = caseSensitive ? text : text.toLowerCase();
const patterns = Array.isArray(matchValue) ? matchValue : [matchValue];
return patterns.some((pattern: string) => {
const searchPattern = caseSensitive ? String(pattern) : String(pattern).toLowerCase();
return searchText.startsWith(searchPattern);
});
}
/**
* Regex: evaluate a regex pattern against the field value.
*/
private evaluateRegex(
fieldValue: string | number,
matchValue: any,
caseSensitive: boolean
): boolean {
const text = String(fieldValue);
const pattern = String(matchValue);
try {
const flags = caseSensitive ? '' : 'i';
const regex = new RegExp(pattern, flags);
return regex.test(text);
} catch {
console.error(`[CLASSIFIER] Invalid regex pattern: ${pattern}`);
return false;
}
}
/**
* Equals: exact match (handles numbers and strings).
*/
private evaluateEquals(
fieldValue: string | number,
matchValue: any
): boolean {
// Compare as numbers if both are numeric
const numField = Number(fieldValue);
const numMatch = Number(matchValue);
if (!isNaN(numField) && !isNaN(numMatch)) {
return numField === numMatch;
}
return String(fieldValue).toLowerCase() === String(matchValue).toLowerCase();
}
/**
* In: check if field value is in the match value array.
*/
private evaluateIn(
fieldValue: string | number,
matchValue: any
): boolean {
const values = Array.isArray(matchValue) ? matchValue : [matchValue];
const numField = Number(fieldValue);
return values.some((v: any) => {
const numV = Number(v);
if (!isNaN(numField) && !isNaN(numV)) {
return numField === numV;
}
return String(fieldValue).toLowerCase() === String(v).toLowerCase();
});
}
/**
* Default branch when no branch routing rule matches.
*/
private defaultBranch(): ClassificationStepResult {
return {
field: 'branch',
value: 'service_desk',
confidence: 'high',
matched_rule_id: null,
matched_rule_name: 'Default (service_desk)',
method: 'robotic',
};
}
/**
* Assess overall confidence and determine if AI enhancement is needed.
*/
private assessConfidence(
results: (ClassificationStepResult | null)[],
ticket: TicketData
): { overallConfidence: ConfidenceLevel; needsAi: boolean; aiReasons: string[] } {
const aiReasons: string[] = [];
// Check for missing classifications
const [branch, ticketType, issueClass, priority, queue] = results;
if (!ticketType) {
aiReasons.push('No ticket type classification matched');
}
if (!issueClass) {
aiReasons.push('No issue type classification matched');
}
if (!priority) {
aiReasons.push('No priority classification matched');
}
// Check for low confidence results
for (const result of results) {
if (result && result.confidence === 'low') {
aiReasons.push(`Low confidence on ${result.field}: ${result.matched_rule_name}`);
}
}
// Check if title looks like it needs cleanup (email subject, too long, garbled)
if (ticket.title) {
if (ticket.title.length > 150) {
aiReasons.push('Title is very long (possible email subject)');
}
if (/^(re:|fw:|fwd:)/i.test(ticket.title)) {
aiReasons.push('Title is a forwarded/replied email subject');
}
}
// Determine overall confidence
let overallConfidence: ConfidenceLevel;
if (aiReasons.length === 0) {
overallConfidence = 'high';
} else if (aiReasons.length <= 2) {
overallConfidence = 'medium';
} else {
overallConfidence = 'low';
}
return {
overallConfidence,
needsAi: aiReasons.length > 0,
aiReasons,
};
}
/**
* Force refresh the rules cache.
*/
async refreshCache(): Promise<void> {
await this.loadRules(true);
}
}
// Export singleton instance
export const roboticClassifier = new RoboticClassifier();

View file

@ -0,0 +1,151 @@
/**
* Triage Validator
* Validates classification output against DB picklists.
* Ensures issueType/subIssueType parent-child relationships are correct,
* priority exists, queue exists, etc.
*/
import { postgresClient } from './postgres-client';
import {
ClassificationResult,
ValidationResult,
ValidationError,
} from '../types/workflow';
// Cache TTL for picklist data (10 minutes)
const PICKLIST_CACHE_TTL_MS = 10 * 60 * 1000;
interface PicklistCache {
issueTypes: Set<number>;
subIssueTypes: Map<number, number>; // value → parent_value
priorities: Set<number>;
queues: Set<number>;
ticketCategories: Set<number>;
loadedAt: number;
}
export class TriageValidator {
private cache: PicklistCache | null = null;
/**
* Load picklist data from DB into cache.
*/
private async loadPicklists(forceRefresh = false): Promise<PicklistCache> {
const now = Date.now();
if (!forceRefresh && this.cache && now - this.cache.loadedAt < PICKLIST_CACHE_TTL_MS) {
return this.cache;
}
const [issueTypes, subIssueTypes, priorities, queues, ticketCategories] = await Promise.all([
postgresClient.query(`SELECT value FROM issue_types WHERE is_deleted = false`),
postgresClient.query(`SELECT value, parent_value FROM sub_issue_types WHERE is_deleted = false`),
postgresClient.query(`SELECT value FROM priorities WHERE is_active = true`),
postgresClient.query(`SELECT value FROM queues WHERE is_active = true`),
postgresClient.query(`SELECT value FROM ticket_categories WHERE is_active = true`),
]);
this.cache = {
issueTypes: new Set(issueTypes.rows.map((r: any) => Number(r.value))),
subIssueTypes: new Map(
subIssueTypes.rows.map((r: any) => [Number(r.value), Number(r.parent_value)])
),
priorities: new Set(priorities.rows.map((r: any) => Number(r.value))),
queues: new Set(queues.rows.map((r: any) => Number(r.value))),
ticketCategories: new Set(ticketCategories.rows.map((r: any) => Number(r.value))),
loadedAt: now,
};
console.log(
`[VALIDATOR] Loaded picklists: ${this.cache.issueTypes.size} issueTypes, ` +
`${this.cache.subIssueTypes.size} subIssueTypes, ${this.cache.priorities.size} priorities, ` +
`${this.cache.queues.size} queues, ${this.cache.ticketCategories.size} ticketCategories`
);
return this.cache;
}
/**
* Validate a classification result against DB picklists.
*/
async validate(classification: ClassificationResult): Promise<ValidationResult> {
const picklists = await this.loadPicklists();
const errors: ValidationError[] = [];
// Validate issue type
if (classification.issue_classification) {
const issueTypeValue = Number(classification.issue_classification.value);
if (!picklists.issueTypes.has(issueTypeValue)) {
errors.push({
field: 'issue_type',
message: `Issue type ${issueTypeValue} not found in picklist`,
value: issueTypeValue,
});
}
// Validate sub-issue type
if (classification.issue_classification.value_2 != null) {
const subIssueTypeValue = Number(classification.issue_classification.value_2);
if (!picklists.subIssueTypes.has(subIssueTypeValue)) {
errors.push({
field: 'sub_issue_type',
message: `Sub-issue type ${subIssueTypeValue} not found in picklist`,
value: subIssueTypeValue,
});
} else {
// Validate parent-child relationship
const parentValue = picklists.subIssueTypes.get(subIssueTypeValue);
if (parentValue !== issueTypeValue) {
errors.push({
field: 'sub_issue_type',
message: `Sub-issue type ${subIssueTypeValue} has parent ${parentValue}, but issue type is ${issueTypeValue}`,
value: subIssueTypeValue,
});
}
}
}
}
// Validate priority
if (classification.priority) {
const priorityValue = Number(classification.priority.value);
if (!picklists.priorities.has(priorityValue)) {
errors.push({
field: 'priority',
message: `Priority ${priorityValue} not found in picklist`,
value: priorityValue,
});
}
}
// Validate queue
if (classification.queue) {
const queueValue = Number(classification.queue.value);
if (!picklists.queues.has(queueValue)) {
errors.push({
field: 'queue_id',
message: `Queue ${queueValue} not found in picklist`,
value: queueValue,
});
}
}
if (errors.length > 0) {
console.warn(`[VALIDATOR] Validation failed with ${errors.length} error(s):`, errors);
}
return {
is_valid: errors.length === 0,
errors,
};
}
/**
* Force refresh the picklist cache.
*/
async refreshCache(): Promise<void> {
await this.loadPicklists(true);
}
}
// Export singleton instance
export const triageValidator = new TriageValidator();

View file

@ -6,6 +6,8 @@ import {
VspcBackupAgentJob,
VspcProtectedWorkload,
VspcRepository,
VspcBackupAgent,
VspcAlarm,
} from '@/lib/types/veeam';
export interface VeeamClientConfig {
@ -129,16 +131,20 @@ export class VeeamClient {
const url = this.buildUrl(path, params);
const response = await this.makeApiCall<VspcListResponse<T>>(url);
if (response.data && response.data.length > 0) {
allItems.push(...response.data);
const pageData = response.data ?? [];
if (pageData.length > 0) {
allItems.push(...pageData);
}
total = response.meta?.pagingInfo?.total ?? 0;
offset += this.DEFAULT_PAGE_SIZE;
if (response.data.length > 0) {
if (pageData.length > 0) {
console.log(`Veeam VSPC: fetched ${allItems.length}/${total} from ${path}`);
}
// Safety: if page returned nothing and we haven't hit total, stop
if (pageData.length === 0) break;
} while (offset < total);
return allItems;
@ -208,6 +214,26 @@ export class VeeamClient {
return repos;
}
/**
* Fetch all backup agents (Veeam agent installs on managed machines)
*/
async getBackupAgents(): Promise<VspcBackupAgent[]> {
console.log('Fetching Veeam VSPC backup agents...');
const agents = await this.fetchAllPages<VspcBackupAgent>('/infrastructure/backupAgents');
console.log(`Fetched ${agents.length} Veeam backup agents`);
return agents;
}
/**
* Fetch all active VSPC alarms
*/
async getActiveAlarms(): Promise<VspcAlarm[]> {
console.log('Fetching Veeam VSPC active alarms...');
const alarms = await this.fetchAllPages<VspcAlarm>('/alarms/active');
console.log(`Fetched ${alarms.length} Veeam active alarms`);
return alarms;
}
/**
* Test API connectivity by fetching a single organization
*/

View file

@ -13,6 +13,8 @@ import {
VspcBackupAgentJob,
VspcProtectedWorkload,
VspcRepository,
VspcBackupAgent,
VspcAlarm,
} from '@/lib/types/veeam';
import { VeeamComplianceService } from './veeam-compliance-service';
@ -97,6 +99,8 @@ export class VeeamSyncService {
{ name: 'backup_jobs', fn: () => this.syncBackupJobs() },
{ name: 'backup_agent_jobs', fn: () => this.syncBackupAgentJobs() },
{ name: 'protected_workloads', fn: () => this.syncProtectedWorkloads() },
{ name: 'backup_agents', fn: () => this.syncBackupAgents() },
{ name: 'alarms', fn: () => this.syncAlarms() },
];
for (const step of steps) {
@ -136,8 +140,8 @@ export class VeeamSyncService {
if (historyId) {
try {
await postgresClient.query(
`UPDATE sync_history SET status = $1, completed_at = $2, records_added = $3, error_message = $4 WHERE id = $5`,
[status, completedAt, totalRecords, errors.length > 0 ? errors.join('; ') : null, historyId]
`UPDATE sync_history SET status = $1, completed_at = $2, records_added = $3, error_message = $4, entity_details = $5 WHERE id = $6`,
[status, completedAt, totalRecords, errors.length > 0 ? errors.join('; ') : null, JSON.stringify(entityResults), historyId]
);
} catch (e) {
console.warn('[VEEAM-SYNC] Could not update sync history:', e);
@ -399,4 +403,101 @@ export class VeeamSyncService {
}
return count;
}
private async syncBackupAgents(): Promise<number> {
const agents = await this.client.getBackupAgents();
if (agents.length === 0) return 0;
const knownOrgs = await postgresClient.query('SELECT instance_uid FROM veeam_organizations');
const orgUids = new Set(knownOrgs.rows.map((r: any) => r.instance_uid));
let count = 0;
for (const a of agents) {
const orgUid = orgUids.has(a.organizationUid) ? a.organizationUid : null;
await postgresClient.query(
`INSERT INTO veeam_backup_agents (instance_uid, organization_uid, site_uid, management_agent_uid,
name, agent_platform, status, management_agent_status, operation_mode, gui_mode,
platform, version, version_status, management_mode, installation_type, activation_time,
total_jobs_count, running_jobs_count, success_jobs_count, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,NOW())
ON CONFLICT (instance_uid) DO UPDATE SET
organization_uid=EXCLUDED.organization_uid, site_uid=EXCLUDED.site_uid,
management_agent_uid=EXCLUDED.management_agent_uid, name=EXCLUDED.name,
agent_platform=EXCLUDED.agent_platform, status=EXCLUDED.status,
management_agent_status=EXCLUDED.management_agent_status, operation_mode=EXCLUDED.operation_mode,
gui_mode=EXCLUDED.gui_mode, platform=EXCLUDED.platform, version=EXCLUDED.version,
version_status=EXCLUDED.version_status, management_mode=EXCLUDED.management_mode,
installation_type=EXCLUDED.installation_type, activation_time=EXCLUDED.activation_time,
total_jobs_count=EXCLUDED.total_jobs_count, running_jobs_count=EXCLUDED.running_jobs_count,
success_jobs_count=EXCLUDED.success_jobs_count, synced_at=NOW(), updated_at=NOW()`,
[
a.instanceUid, orgUid, a.siteUid || null, a.managementAgentUid || null,
a.name || a.instanceUid, a.agentPlatform || null, a.status || null, a.managementAgentStatus || null,
a.operationMode || null, a.guiMode || null, a.platform || null,
a.version || null, a.versionStatus || null, a.managementMode || null,
a.installationType || null, a.activationTime ? new Date(a.activationTime) : null,
a.totalJobsCount ?? 0, a.runningJobsCount ?? 0, a.successJobsCount ?? 0,
]
);
count++;
}
return count;
}
private async syncAlarms(): Promise<number> {
const alarms = await this.client.getActiveAlarms();
if (alarms.length === 0) return 0;
const knownOrgs = await postgresClient.query('SELECT instance_uid FROM veeam_organizations');
const orgUids = new Set(knownOrgs.rows.map((r: any) => r.instance_uid));
let count = 0;
for (const alarm of alarms) {
const orgUid = alarm.object?.organizationUid && orgUids.has(alarm.object.organizationUid)
? alarm.object.organizationUid : null;
const lastStatus = alarm.lastActivation?.status || null;
const resolved = lastStatus === 'Resolved';
await postgresClient.query(
`INSERT INTO veeam_alarms (instance_uid, alarm_template_uid, repeat_count,
object_uid, object_type, object_name, object_computer_name,
organization_uid, location_uid, management_agent_uid,
last_activation_uid, last_activation_time, last_activation_status,
last_activation_message, last_activation_remark, area, resolved, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW())
ON CONFLICT (instance_uid) DO UPDATE SET
alarm_template_uid=EXCLUDED.alarm_template_uid, repeat_count=EXCLUDED.repeat_count,
object_uid=EXCLUDED.object_uid, object_type=EXCLUDED.object_type,
object_name=EXCLUDED.object_name, object_computer_name=EXCLUDED.object_computer_name,
organization_uid=EXCLUDED.organization_uid, location_uid=EXCLUDED.location_uid,
management_agent_uid=EXCLUDED.management_agent_uid,
last_activation_uid=EXCLUDED.last_activation_uid,
last_activation_time=EXCLUDED.last_activation_time,
last_activation_status=EXCLUDED.last_activation_status,
last_activation_message=EXCLUDED.last_activation_message,
last_activation_remark=EXCLUDED.last_activation_remark,
area=EXCLUDED.area, resolved=EXCLUDED.resolved,
synced_at=NOW(), updated_at=NOW()`,
[
alarm.instanceUid, alarm.alarmTemplateUid || null, alarm.repeatCount ?? 0,
alarm.object?.objectUid || alarm.object?.instanceUid || null,
alarm.object?.type || null,
alarm.object?.objectName || null,
alarm.object?.computerName || null,
orgUid,
alarm.object?.locationUid || null,
alarm.object?.managementAgentUid || null,
alarm.lastActivation?.instanceUid || null,
alarm.lastActivation?.time ? new Date(alarm.lastActivation.time) : null,
lastStatus,
alarm.lastActivation?.message || null,
alarm.lastActivation?.remark || null,
alarm.area || null,
resolved,
]
);
count++;
}
return count;
}
}

View file

@ -3,13 +3,59 @@
* Handles incoming webhooks from Autotask for real-time updates
*/
import { createHmac } from 'crypto';
import { postgresClient } from './postgres-client';
import { AutotaskWebhookPayload, WebhookProcessingResult, WebhookLog, WebhookEventType, WebhookEntityType } from '../types/webhook';
import { EntityType } from '../types/sync';
import { mapAutotaskToDatabase } from '../utils/entity-mapper';
import { getTableName } from '../utils/sync-helpers';
import { getTableName, getAutotaskEntityName } from '../utils/sync-helpers';
import { AutotaskClient } from './autotask-client';
import { workflowEngine } from './workflow-engine';
import { WorkflowEvent, TicketData } from '../types/workflow';
export class WebhookService {
private _autotaskClient: AutotaskClient | null = null;
private getAutotaskClient(): AutotaskClient {
if (!this._autotaskClient) {
this._autotaskClient = new AutotaskClient({
apiUrl: process.env.AUTOTASK_API_URL || '',
username: process.env.AUTOTASK_USERNAME || '',
password: process.env.AUTOTASK_SECRET || '',
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
});
}
return this._autotaskClient;
}
/**
* Verify webhook signature.
* Autotask sends: x-hook-signature: sha1=<base64>
* Computed as HMAC-SHA1 of the raw body using the secret key.
*/
verifySignature(rawBody: string, signatureHeader: string | null): boolean {
const secret = process.env.AUTOTASK_WEBHOOK_SECRET;
if (!secret) {
console.warn('[WEBHOOK] No AUTOTASK_WEBHOOK_SECRET configured, skipping signature verification');
return true;
}
if (!signatureHeader) {
console.warn('[WEBHOOK] No x-hook-signature header in webhook request');
return false;
}
// Header format: "sha1=<base64>"
const signature = signatureHeader.startsWith('sha1=')
? signatureHeader.slice(5)
: signatureHeader;
const computed = createHmac('sha1', secret).update(rawBody).digest('base64');
const match = computed === signature;
if (!match) {
console.warn(`[WEBHOOK] Signature mismatch: expected=${computed}, received=${signature}`);
}
return match;
}
/**
* Process an incoming webhook from Autotask
*/
@ -61,6 +107,13 @@ export class WebhookService {
console.log(`[WEBHOOK] Successfully processed ${payload.eventType} for ${payload.entityType} #${payload.entityId} in ${processingTime}ms`);
// Trigger workflow engine for new tickets (fire-and-forget)
if (payload.entityType === WebhookEntityType.TICKETS && payload.eventType === WebhookEventType.CREATE) {
this.triggerWorkflowEngine(payload).catch(err =>
console.error('[WEBHOOK] Workflow engine error:', err)
);
}
return {
success: true,
eventId: payload.eventId,
@ -95,17 +148,28 @@ export class WebhookService {
* Handle create or update events
*/
private async handleCreateOrUpdate(payload: AutotaskWebhookPayload): Promise<'created' | 'updated'> {
// If webhook includes full entity data, use it
if (payload.entity) {
// If webhook includes full entity data, use it directly
if (payload.entity && Object.keys(payload.entity).length > 2) {
await this.upsertEntity(payload.entityType, payload.entity);
return payload.eventType === WebhookEventType.CREATE ? 'created' : 'updated';
}
// Otherwise, fetch the entity from Autotask API
// Note: This requires the AutotaskClient to fetch individual entities
// For now, we'll log and skip - can be enhanced later
console.warn(`[WEBHOOK] Entity data not included in webhook, skipping upsert for ${payload.entityType} #${payload.entityId}`);
return 'updated';
// Fetch the full entity from Autotask API
const internalEntityType = this.mapWebhookEntityType(payload.entityType);
const autotaskEntityName = getAutotaskEntityName(internalEntityType);
console.log(`[WEBHOOK] Fetching ${autotaskEntityName} #${payload.entityId} from Autotask API`);
const client = this.getAutotaskClient();
const entity = await client.getEntityById(autotaskEntityName, payload.entityId);
if (!entity) {
console.warn(`[WEBHOOK] Entity ${autotaskEntityName} #${payload.entityId} not found in Autotask API`);
return payload.eventType === WebhookEventType.CREATE ? 'created' : 'updated';
}
await this.upsertEntity(payload.entityType, entity as Record<string, any>);
return payload.eventType === WebhookEventType.CREATE ? 'created' : 'updated';
}
/**
@ -263,21 +327,16 @@ export class WebhookService {
}> {
const query = `
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE status = 'processed') as processed,
COUNT(*) FILTER (WHERE status = 'failed') as failed,
COUNT(*) FILTER (WHERE status = 'pending') as pending,
jsonb_object_agg(entity_type, entity_count) as by_entity_type
FROM (
SELECT
entity_type,
COUNT(*) as entity_count
(SELECT COUNT(*) FROM webhook_logs WHERE received_at >= NOW() - INTERVAL '${hours} hours') as total,
(SELECT COUNT(*) FROM webhook_logs WHERE received_at >= NOW() - INTERVAL '${hours} hours' AND status = 'processed') as processed,
(SELECT COUNT(*) FROM webhook_logs WHERE received_at >= NOW() - INTERVAL '${hours} hours' AND status = 'failed') as failed,
(SELECT COUNT(*) FROM webhook_logs WHERE received_at >= NOW() - INTERVAL '${hours} hours' AND status = 'pending') as pending,
(SELECT COALESCE(jsonb_object_agg(entity_type, entity_count), '{}'::jsonb) FROM (
SELECT entity_type, COUNT(*) as entity_count
FROM webhook_logs
WHERE received_at >= NOW() - INTERVAL '${hours} hours'
GROUP BY entity_type
) entity_counts,
webhook_logs
WHERE received_at >= NOW() - INTERVAL '${hours} hours'
) ec) as by_entity_type
`;
const result = await postgresClient.query(query);
@ -309,6 +368,7 @@ export class WebhookService {
const mapping: Record<WebhookEntityType, EntityType> = {
[WebhookEntityType.COMPANIES]: EntityType.COMPANIES,
[WebhookEntityType.TICKETS]: EntityType.TICKETS,
[WebhookEntityType.TICKET_NOTES]: EntityType.TICKET_NOTES,
[WebhookEntityType.TASKS]: EntityType.TASKS,
[WebhookEntityType.PROJECTS]: EntityType.PROJECTS,
[WebhookEntityType.TIME_ENTRIES]: EntityType.TIME_ENTRIES,
@ -327,6 +387,45 @@ export class WebhookService {
const entityType = this.mapWebhookEntityType(webhookType);
return getTableName(entityType);
}
/**
* Trigger the workflow engine for a new ticket.
* Runs asynchronously does not block webhook response.
*/
private async triggerWorkflowEngine(payload: AutotaskWebhookPayload): Promise<void> {
const event: WorkflowEvent = {
trigger_event: 'ticket.created',
entity_type: 'ticket',
entity_id: payload.entityId,
ticket_number: payload.fields?.ticketNumber || undefined,
};
// If the webhook payload includes the full entity, build TicketData from it
if (payload.entity) {
event.ticket_data = {
id: payload.entityId,
ticket_number: payload.entity.ticketNumber || null,
title: payload.entity.title || '',
description: payload.entity.description || null,
ticket_category: payload.entity.ticketCategory || null,
ticket_type: payload.entity.ticketType || null,
priority: payload.entity.priority || null,
queue_id: payload.entity.queueID || null,
issue_type: payload.entity.issueType || null,
sub_issue_type: payload.entity.subIssueType || null,
company_id: payload.entity.companyID || 0,
contact_id: payload.entity.contactID || null,
assigned_resource_id: payload.entity.assignedResourceID || null,
creator_resource_id: payload.entity.creatorResourceID || null,
person_id: payload.personId || null,
status: payload.entity.status || null,
source: payload.entity.source || null,
};
}
console.log(`[WEBHOOK] Triggering workflow engine for ticket ${payload.entityId}`);
await workflowEngine.process(event);
}
}
// Export singleton instance

View file

@ -0,0 +1,954 @@
/**
* Workflow Engine
* Orchestrates the full ticket triage pipeline:
* 1. Check if enabled
* 2. Run exclusion filter rules
* 3. Run robotic classification
* 4. Validate classification
* 5. If needed AI enhancement
* 6. Re-validate after AI
* 7. Delay (configurable)
* 8. Write back to Autotask
* 9. Create notes if applicable
* 10. Log full execution
*/
import { postgresClient } from './postgres-client';
import { AutotaskClient } from './autotask-client';
import { roboticClassifier } from './robotic-classifier';
import { triageValidator } from './triage-validator';
import { aiTriageService } from './ai-triage-service';
import {
WorkflowEvent,
ExecutionResult,
WorkflowSettings,
TicketData,
ClassificationResult,
FieldChanges,
ExecutionStepSummary,
WorkflowRuleWithDetails,
WorkflowCondition,
StepName,
ExecutionStatus,
StepMethod,
ConfidenceLevel,
Branch,
ClassificationMethod,
} from '../types/workflow';
export class WorkflowEngine {
private _autotaskClient: AutotaskClient | null = null;
private getAutotaskClient(): AutotaskClient {
if (!this._autotaskClient) {
this._autotaskClient = new AutotaskClient({
apiUrl: process.env.AUTOTASK_API_URL || '',
username: process.env.AUTOTASK_USERNAME || '',
password: process.env.AUTOTASK_SECRET || '',
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
});
}
return this._autotaskClient;
}
/**
* Load workflow settings from DB.
*/
async getSettings(): Promise<WorkflowSettings> {
const result = await postgresClient.query(
`SELECT key, value FROM workflow_settings`
);
const raw: Record<string, any> = {};
for (const row of result.rows) {
try {
raw[row.key] = JSON.parse(row.value);
} catch {
raw[row.key] = row.value;
}
}
return {
workflow_engine_enabled: raw.workflow_engine_enabled ?? false,
default_ai_provider: raw.default_ai_provider ?? 'openai',
openai_api_key: raw.openai_api_key ?? '',
openai_model: raw.openai_model ?? 'gpt-4o',
anthropic_api_key: raw.anthropic_api_key ?? '',
anthropic_model: raw.anthropic_model ?? 'claude-sonnet-4-20250514',
ai_for_title_cleanup: raw.ai_for_title_cleanup ?? true,
ai_for_description_rewrite: raw.ai_for_description_rewrite ?? true,
ai_for_ambiguous_classification: raw.ai_for_ambiguous_classification ?? true,
ai_for_troubleshooting: raw.ai_for_troubleshooting ?? true,
autotask_update_delay_ms: Number(raw.autotask_update_delay_ms) || 30000,
max_ai_retries: Number(raw.max_ai_retries) || 2,
classification_confidence_threshold: raw.classification_confidence_threshold ?? 'medium',
log_retention_days: Number(raw.log_retention_days) || 90,
};
}
/**
* Main entry point: process a workflow event.
*/
async process(event: WorkflowEvent): Promise<ExecutionResult> {
const startTime = Date.now();
const steps: ExecutionStepSummary[] = [];
const fieldChanges: FieldChanges = {};
let executionId: number = 0;
let classificationMethod: ClassificationMethod = 'robotic';
let branch: Branch = 'service_desk';
try {
// 1. Check if engine is enabled
const settings = await this.getSettings();
if (!settings.workflow_engine_enabled) {
console.log('[WORKFLOW] Engine is disabled, skipping');
return this.buildSkippedResult(0, 'Engine disabled');
}
// Create execution record
executionId = await this.createExecution(event);
// 2. Load ticket data
const ticket = event.ticket_data || await this.loadTicketData(event.entity_id);
if (!ticket) {
await this.updateExecution(executionId, 'failed', null, null, 'Ticket not found');
return this.buildSkippedResult(executionId, 'Ticket not found');
}
// 3. Run exclusion filter rules
const filterStep = await this.runFilterRules(ticket, event.trigger_event, executionId);
steps.push(filterStep);
if (filterStep.status === 'completed' && filterStep.method === 'skipped') {
await this.updateExecution(executionId, 'skipped', null, null);
return {
execution_id: executionId,
status: 'skipped',
classification_method: 'robotic',
branch: 'service_desk',
field_changes: {},
steps,
duration_ms: Date.now() - startTime,
};
}
// 4. Run robotic classification
const classifyStart = Date.now();
const classification = await roboticClassifier.classify(ticket);
branch = (classification.branch?.value as Branch) || 'service_desk';
// Log classification steps
const classificationSteps = this.buildClassificationSteps(classification, executionId, classifyStart);
steps.push(...classificationSteps);
// 5. Validate classification
const validationStep = await this.runValidation(classification, executionId);
steps.push(validationStep);
// 6. AI Enhancement (if needed)
let finalClassification = classification;
const confidenceThreshold = settings.classification_confidence_threshold;
if (
classification.needs_ai &&
settings.ai_for_ambiguous_classification &&
this.shouldUseAi(classification.overall_confidence, confidenceThreshold)
) {
classificationMethod = 'hybrid';
// AI for ambiguous classification
const failedFields = classification.ai_reasons
.filter(r => r.includes('No ') && r.includes('matched'))
.map(r => {
if (r.includes('ticket type')) return 'ticket_type';
if (r.includes('issue type')) return 'issue_type';
if (r.includes('priority')) return 'priority';
return '';
})
.filter(Boolean);
if (failedFields.length > 0) {
const aiClassifyStep = await this.runAiClassification(
ticket, failedFields, finalClassification, settings, executionId
);
steps.push(aiClassifyStep);
}
}
// AI for title cleanup
if (
settings.ai_for_title_cleanup &&
classification.ai_reasons.some(r => r.includes('Title'))
) {
classificationMethod = classificationMethod === 'robotic' ? 'hybrid' : classificationMethod;
const titleStep = await this.runAiTitleCleanup(ticket, settings, executionId);
steps.push(titleStep);
if (titleStep.status === 'completed' && (titleStep as any)._newTitle) {
fieldChanges['title'] = { before: ticket.title, after: (titleStep as any)._newTitle };
}
}
// 7. Build field changes for write-back
this.buildFieldChanges(ticket, finalClassification, fieldChanges);
// 8. Delay before Autotask write-back
if (Object.keys(fieldChanges).length > 0 && settings.autotask_update_delay_ms > 0) {
await this.delay(settings.autotask_update_delay_ms);
}
// 9. Write back to Autotask
if (Object.keys(fieldChanges).length > 0) {
const updateStep = await this.writeBackToAutotask(
ticket.id, fieldChanges, executionId
);
steps.push(updateStep);
}
// 10. Create troubleshooting note for incidents
const ticketTypeValue = finalClassification.ticket_type?.value ?? ticket.ticket_type;
if (
Number(ticketTypeValue) === 2 &&
settings.ai_for_troubleshooting
) {
classificationMethod = classificationMethod === 'robotic' ? 'hybrid' : classificationMethod;
const noteStep = await this.runAiTroubleshooting(ticket, settings, executionId);
steps.push(noteStep);
}
// 11. Update execution record
const duration = Date.now() - startTime;
await this.updateExecution(executionId, 'completed', classificationMethod, branch);
console.log(
`[WORKFLOW] Completed processing ticket #${ticket.ticket_number} ` +
`(${classificationMethod}, ${branch}) in ${duration}ms`
);
return {
execution_id: executionId,
status: 'completed',
classification_method: classificationMethod,
branch,
field_changes: fieldChanges,
steps,
duration_ms: duration,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('[WORKFLOW] Pipeline error:', errorMessage);
if (executionId) {
await this.updateExecution(executionId, 'failed', classificationMethod, branch, errorMessage);
}
return {
execution_id: executionId,
status: 'failed',
classification_method: classificationMethod,
branch,
field_changes: fieldChanges,
steps,
duration_ms: Date.now() - startTime,
error: errorMessage,
};
}
}
/**
* Dry-run: process a ticket without writing back to Autotask.
*/
async dryRun(ticketId: number): Promise<ExecutionResult> {
const settings = await this.getSettings();
const ticket = await this.loadTicketData(ticketId);
if (!ticket) {
return this.buildSkippedResult(0, 'Ticket not found');
}
const startTime = Date.now();
const steps: ExecutionStepSummary[] = [];
// Run filter rules
const filterStep = await this.runFilterRules(ticket, 'ticket.created', 0);
steps.push(filterStep);
// Run classification
const classification = await roboticClassifier.classify(ticket);
const branch = (classification.branch?.value as Branch) || 'service_desk';
const classificationSteps = this.buildClassificationSteps(classification, 0, Date.now());
steps.push(...classificationSteps);
// Run validation
const validation = await triageValidator.validate(classification);
steps.push({
step_name: 'validation',
status: validation.is_valid ? 'completed' : 'failed',
method: 'robotic',
duration_ms: 0,
});
// Build proposed field changes
const fieldChanges: FieldChanges = {};
this.buildFieldChanges(ticket, classification, fieldChanges);
return {
execution_id: 0,
status: filterStep.method === 'skipped' ? 'skipped' : 'completed',
classification_method: 'robotic',
branch,
field_changes: fieldChanges,
steps,
duration_ms: Date.now() - startTime,
};
}
// ============================================================================
// Pipeline Steps
// ============================================================================
/**
* Run workflow filter rules (exclusion/inclusion).
*/
private async runFilterRules(
ticket: TicketData,
triggerEvent: string,
executionId: number
): Promise<ExecutionStepSummary> {
const stepStart = Date.now();
try {
const rules = await this.loadWorkflowRules(triggerEvent);
for (const rule of rules) {
if (this.evaluateConditions(rule.conditions, ticket)) {
// Check if any action is 'skip'
const skipAction = rule.actions.find(a => a.action_type === 'skip');
if (skipAction) {
const reason = skipAction.config?.reason || rule.name;
console.log(`[WORKFLOW] Ticket #${ticket.ticket_number} skipped: ${reason}`);
if (executionId > 0) {
await this.logStep(executionId, 'filter', 1, 'completed', 'skipped', {
rule_name: rule.name,
reason,
});
}
return {
step_name: 'filter',
status: 'completed',
method: 'skipped',
matched_rule: rule.name,
duration_ms: Date.now() - stepStart,
};
}
if (rule.stop_processing) break;
}
}
if (executionId > 0) {
await this.logStep(executionId, 'filter', 1, 'completed', 'robotic', {
result: 'passed_all_filters',
});
}
return {
step_name: 'filter',
status: 'completed',
method: 'robotic',
duration_ms: Date.now() - stepStart,
};
} catch (error) {
console.error('[WORKFLOW] Filter rules error:', error);
return {
step_name: 'filter',
status: 'failed',
method: 'robotic',
duration_ms: Date.now() - stepStart,
};
}
}
/**
* Build step summaries from classification result.
*/
private buildClassificationSteps(
classification: ClassificationResult,
executionId: number,
startTime: number
): ExecutionStepSummary[] {
const steps: ExecutionStepSummary[] = [];
const entries: [StepName, any][] = [
['branch_routing', classification.branch],
['ticket_type', classification.ticket_type],
['issue_classification', classification.issue_classification],
['priority', classification.priority],
['queue_routing', classification.queue],
];
let order = 2;
for (const [stepName, result] of entries) {
steps.push({
step_name: stepName,
status: result ? 'completed' : 'completed',
method: result?.method || 'robotic',
confidence: result?.confidence,
matched_rule: result?.matched_rule_name || (result ? undefined : 'No match'),
duration_ms: 0,
});
if (executionId > 0 && result) {
this.logStep(executionId, stepName, order, 'completed', result.method, {
field: result.field,
value: result.value,
confidence: result.confidence,
matched_rule: result.matched_rule_name,
}, result.matched_rule_id).catch(err =>
console.error(`[WORKFLOW] Failed to log step ${stepName}:`, err)
);
}
order++;
}
return steps;
}
/**
* Run validation step.
*/
private async runValidation(
classification: ClassificationResult,
executionId: number
): Promise<ExecutionStepSummary> {
const stepStart = Date.now();
const validation = await triageValidator.validate(classification);
if (executionId > 0) {
await this.logStep(executionId, 'validation', 7, validation.is_valid ? 'completed' : 'failed', 'robotic', {
is_valid: validation.is_valid,
errors: validation.errors,
});
}
return {
step_name: 'validation',
status: validation.is_valid ? 'completed' : 'failed',
method: 'robotic',
duration_ms: Date.now() - stepStart,
};
}
/**
* Run AI classification for ambiguous fields.
*/
private async runAiClassification(
ticket: TicketData,
failedFields: string[],
classification: ClassificationResult,
settings: WorkflowSettings,
executionId: number
): Promise<ExecutionStepSummary> {
const stepStart = Date.now();
try {
const aiResult = await aiTriageService.classifyAmbiguous(ticket, failedFields, settings);
// Merge AI results into classification
if (aiResult.classification) {
if (aiResult.classification.issue_type != null && !classification.issue_classification) {
classification.issue_classification = {
field: 'issue_type',
value: aiResult.classification.issue_type,
field_2: 'sub_issue_type',
value_2: aiResult.classification.sub_issue_type,
confidence: 'medium',
matched_rule_id: null,
matched_rule_name: 'AI classification',
method: 'ai',
};
}
if (aiResult.classification.ticket_type != null && !classification.ticket_type) {
classification.ticket_type = {
field: 'ticket_type',
value: aiResult.classification.ticket_type,
confidence: 'medium',
matched_rule_id: null,
matched_rule_name: 'AI classification',
method: 'ai',
};
}
if (aiResult.classification.priority != null && !classification.priority) {
classification.priority = {
field: 'priority',
value: aiResult.classification.priority,
confidence: 'medium',
matched_rule_id: null,
matched_rule_name: 'AI classification',
method: 'ai',
};
}
}
if (executionId > 0) {
await this.logStep(executionId, 'ai_classification', 8, 'completed', 'ai', {
failed_fields: failedFields,
ai_result: aiResult.classification,
});
}
return {
step_name: 'ai_classification',
status: 'completed',
method: 'ai',
duration_ms: Date.now() - stepStart,
};
} catch (error) {
console.error('[WORKFLOW] AI classification error:', error);
if (executionId > 0) {
await this.logStep(executionId, 'ai_classification', 8, 'failed', 'ai', {
error: error instanceof Error ? error.message : String(error),
});
}
return {
step_name: 'ai_classification',
status: 'failed',
method: 'ai',
duration_ms: Date.now() - stepStart,
};
}
}
/**
* Run AI title cleanup.
*/
private async runAiTitleCleanup(
ticket: TicketData,
settings: WorkflowSettings,
executionId: number
): Promise<ExecutionStepSummary & { _newTitle?: string }> {
const stepStart = Date.now();
try {
const newTitle = await aiTriageService.cleanupTitle(ticket, settings);
if (executionId > 0) {
await this.logStep(executionId, 'ai_title', 9, 'completed', 'ai', {
original: ticket.title,
cleaned: newTitle,
});
}
return {
step_name: 'ai_title',
status: 'completed',
method: 'ai',
duration_ms: Date.now() - stepStart,
_newTitle: newTitle,
};
} catch (error) {
console.error('[WORKFLOW] AI title cleanup error:', error);
return {
step_name: 'ai_title',
status: 'failed',
method: 'ai',
duration_ms: Date.now() - stepStart,
};
}
}
/**
* Run AI troubleshooting steps generation and create a ticket note.
*/
private async runAiTroubleshooting(
ticket: TicketData,
settings: WorkflowSettings,
executionId: number
): Promise<ExecutionStepSummary> {
const stepStart = Date.now();
try {
const steps = await aiTriageService.generateTroubleshootingSteps(ticket, settings);
if (!steps) {
return {
step_name: 'ai_troubleshooting',
status: 'completed',
method: 'skipped',
duration_ms: Date.now() - stepStart,
};
}
// Create ticket note in Autotask
const client = this.getAutotaskClient();
await client.createEntity('TicketNotes', {
ticketID: ticket.id,
title: 'Troubleshooting Steps (Auto-Generated)',
description: steps,
noteType: 1, // Internal
publish: 1,
});
if (executionId > 0) {
await this.logStep(executionId, 'create_note', 12, 'completed', 'ai', {
note_type: 'troubleshooting_steps',
content_length: steps.length,
});
}
return {
step_name: 'create_note',
status: 'completed',
method: 'ai',
duration_ms: Date.now() - stepStart,
};
} catch (error) {
console.error('[WORKFLOW] Troubleshooting note error:', error);
return {
step_name: 'create_note',
status: 'failed',
method: 'ai',
duration_ms: Date.now() - stepStart,
};
}
}
/**
* Write classified fields back to Autotask.
*/
private async writeBackToAutotask(
ticketId: number,
fieldChanges: FieldChanges,
executionId: number
): Promise<ExecutionStepSummary> {
const stepStart = Date.now();
try {
// Build Autotask update payload (camelCase field names)
const updatePayload: Record<string, any> = { id: ticketId };
const fieldMap: Record<string, string> = {
ticket_type: 'ticketType',
issue_type: 'issueType',
sub_issue_type: 'subIssueType',
priority: 'priority',
queue_id: 'queueID',
title: 'title',
};
for (const [field, change] of Object.entries(fieldChanges)) {
const autotaskField = fieldMap[field] || field;
updatePayload[autotaskField] = change.after;
}
const client = this.getAutotaskClient();
await client.updateTicket(ticketId, updatePayload);
// Also update local DB
const dbUpdates: Record<string, any> = {};
for (const [field, change] of Object.entries(fieldChanges)) {
dbUpdates[field] = change.after;
}
if (Object.keys(dbUpdates).length > 0) {
const setClauses = Object.keys(dbUpdates).map((k, i) => `${k} = $${i + 2}`);
await postgresClient.query(
`UPDATE tickets SET ${setClauses.join(', ')}, updated_at = NOW() WHERE id = $1`,
[ticketId, ...Object.values(dbUpdates)]
);
}
if (executionId > 0) {
await this.logStep(executionId, 'autotask_update', 11, 'completed', 'robotic', {
fields_updated: Object.keys(fieldChanges),
});
}
console.log(`[WORKFLOW] Updated ticket ${ticketId} in Autotask: ${Object.keys(fieldChanges).join(', ')}`);
return {
step_name: 'autotask_update',
status: 'completed',
method: 'robotic',
duration_ms: Date.now() - stepStart,
};
} catch (error) {
console.error('[WORKFLOW] Autotask update error:', error);
if (executionId > 0) {
await this.logStep(executionId, 'autotask_update', 11, 'failed', 'robotic', {
error: error instanceof Error ? error.message : String(error),
});
}
return {
step_name: 'autotask_update',
status: 'failed',
method: 'robotic',
duration_ms: Date.now() - stepStart,
};
}
}
// ============================================================================
// Helpers
// ============================================================================
/**
* Load ticket data from DB.
*/
private async loadTicketData(ticketId: number): Promise<TicketData | null> {
const result = await postgresClient.query(
`SELECT id, ticket_number, title, description, ticket_category, ticket_type,
priority, queue_id, issue_type, sub_issue_type, company_id, contact_id,
assigned_resource_id, status, source
FROM tickets WHERE id = $1`,
[ticketId]
);
if (result.rows.length === 0) return null;
const row = result.rows[0];
return {
...row,
// Try to extract device/policy info from title/description for queue routing
device_name: this.extractDeviceName(row.title, row.description),
policy_name: this.extractPolicyName(row.description),
creator_resource_id: null, // Not stored on tickets table — comes from webhook
person_id: null,
};
}
/**
* Extract device name patterns from text (e.g., DT-1234, LT-5678).
*/
private extractDeviceName(title: string | null, description: string | null): string | null {
const text = [title, description].filter(Boolean).join(' ');
const match = text.match(/\b(DT|LT|SP|SRV|VM)-?\d{2,}/i);
return match ? match[0] : null;
}
/**
* Extract policy name from description (e.g., "Policy: Windows Workstation").
*/
private extractPolicyName(description: string | null): string | null {
if (!description) return null;
const match = description.match(/(?:policy|monitoring policy)[:\s]+([^\n]+)/i);
return match ? match[1].trim() : null;
}
/**
* Load workflow filter rules with conditions and actions.
*/
private async loadWorkflowRules(triggerEvent: string): Promise<WorkflowRuleWithDetails[]> {
const rulesResult = await postgresClient.query(
`SELECT * FROM workflow_rules WHERE is_active = true AND trigger_event = $1 ORDER BY sort_order`,
[triggerEvent]
);
const rules: WorkflowRuleWithDetails[] = [];
for (const rule of rulesResult.rows) {
const [conditionsResult, actionsResult] = await Promise.all([
postgresClient.query(`SELECT * FROM workflow_conditions WHERE rule_id = $1`, [rule.id]),
postgresClient.query(`SELECT * FROM workflow_actions WHERE rule_id = $1 ORDER BY sort_order`, [rule.id]),
]);
rules.push({
...rule,
conditions: conditionsResult.rows,
actions: actionsResult.rows,
});
}
return rules;
}
/**
* Evaluate workflow conditions against ticket data.
* AND within condition_group, OR between groups.
*/
private evaluateConditions(conditions: WorkflowCondition[], ticket: TicketData): boolean {
if (conditions.length === 0) return true;
// Group conditions by condition_group
const groups = new Map<number, WorkflowCondition[]>();
for (const cond of conditions) {
const group = groups.get(cond.condition_group) || [];
group.push(cond);
groups.set(cond.condition_group, group);
}
// OR between groups: at least one group must pass
for (const [, groupConditions] of groups) {
const groupPasses = groupConditions.every(cond => this.evaluateCondition(cond, ticket));
if (groupPasses) return true;
}
return false;
}
/**
* Evaluate a single workflow condition.
*/
private evaluateCondition(condition: WorkflowCondition, ticket: TicketData): boolean {
const fieldValue = this.getTicketFieldValue(condition.field, ticket);
switch (condition.operator) {
case 'equals':
return this.isEqual(fieldValue, condition.value);
case 'not_equals':
return !this.isEqual(fieldValue, condition.value);
case 'in':
return this.isIn(fieldValue, condition.value);
case 'not_in':
return !this.isIn(fieldValue, condition.value);
case 'contains':
return fieldValue != null && String(fieldValue).toLowerCase().includes(String(condition.value).toLowerCase());
case 'not_contains':
return fieldValue == null || !String(fieldValue).toLowerCase().includes(String(condition.value).toLowerCase());
case 'regex':
try {
return fieldValue != null && new RegExp(String(condition.value), 'i').test(String(fieldValue));
} catch { return false; }
case 'gt':
return fieldValue != null && Number(fieldValue) > Number(condition.value);
case 'lt':
return fieldValue != null && Number(fieldValue) < Number(condition.value);
case 'is_null':
return fieldValue == null;
case 'is_not_null':
return fieldValue != null;
default:
return false;
}
}
private getTicketFieldValue(field: string, ticket: TicketData): any {
return (ticket as any)[field] ?? null;
}
private isEqual(a: any, b: any): boolean {
if (a == null && b == null) return true;
if (a == null || b == null) return false;
return String(a) === String(b);
}
private isIn(value: any, list: any): boolean {
if (value == null) return false;
const arr = Array.isArray(list) ? list : [list];
return arr.some(v => String(v) === String(value));
}
/**
* Build field changes from classification result.
*/
private buildFieldChanges(
ticket: TicketData,
classification: ClassificationResult,
fieldChanges: FieldChanges
): void {
if (classification.ticket_type?.value != null && classification.ticket_type.value !== ticket.ticket_type) {
fieldChanges['ticket_type'] = { before: ticket.ticket_type, after: Number(classification.ticket_type.value) };
}
if (classification.issue_classification?.value != null && Number(classification.issue_classification.value) !== ticket.issue_type) {
fieldChanges['issue_type'] = { before: ticket.issue_type, after: Number(classification.issue_classification.value) };
}
if (classification.issue_classification?.value_2 != null && Number(classification.issue_classification.value_2) !== ticket.sub_issue_type) {
fieldChanges['sub_issue_type'] = { before: ticket.sub_issue_type, after: Number(classification.issue_classification.value_2) };
}
if (classification.priority?.value != null && Number(classification.priority.value) !== ticket.priority) {
fieldChanges['priority'] = { before: ticket.priority, after: Number(classification.priority.value) };
}
if (classification.queue?.value != null && Number(classification.queue.value) !== ticket.queue_id) {
fieldChanges['queue_id'] = { before: ticket.queue_id, after: Number(classification.queue.value) };
}
}
/**
* Determine if AI should be used based on confidence.
*/
private shouldUseAi(confidence: ConfidenceLevel, threshold: ConfidenceLevel): boolean {
const levels: Record<ConfidenceLevel, number> = { high: 3, medium: 2, low: 1 };
return levels[confidence] < levels[threshold];
}
/**
* Create a workflow execution record.
*/
private async createExecution(event: WorkflowEvent): Promise<number> {
const result = await postgresClient.query(
`INSERT INTO workflow_executions (trigger_event, entity_type, entity_id, ticket_number, status)
VALUES ($1, $2, $3, $4, 'running')
RETURNING id`,
[event.trigger_event, event.entity_type, event.entity_id, event.ticket_number || null]
);
return result.rows[0].id;
}
/**
* Update execution status.
*/
private async updateExecution(
executionId: number,
status: ExecutionStatus,
method: ClassificationMethod | null,
branch: Branch | null,
errorMessage?: string
): Promise<void> {
await postgresClient.query(
`UPDATE workflow_executions
SET status = $1, classification_method = $2, branch = $3,
completed_at = NOW(), duration_ms = EXTRACT(EPOCH FROM (NOW() - started_at)) * 1000,
error_message = $4
WHERE id = $5`,
[status, method, branch, errorMessage || null, executionId]
);
}
/**
* Log an execution step.
*/
private async logStep(
executionId: number,
stepName: string,
stepOrder: number,
status: ExecutionStatus,
method: StepMethod,
outputData?: Record<string, any>,
classificationRuleId?: number | null
): Promise<void> {
await postgresClient.query(
`INSERT INTO workflow_execution_steps
(execution_id, step_name, step_order, status, method, output_data, classification_rule_id, completed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`,
[executionId, stepName, stepOrder, status, method, outputData ? JSON.stringify(outputData) : null, classificationRuleId || null]
);
}
/**
* Build a skipped execution result.
*/
private buildSkippedResult(executionId: number, reason: string): ExecutionResult {
return {
execution_id: executionId,
status: 'skipped',
classification_method: 'robotic',
branch: 'service_desk',
field_changes: {},
steps: [],
duration_ms: 0,
error: reason,
};
}
/**
* Delay for a specified number of milliseconds.
*/
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Export singleton instance
export const workflowEngine = new WorkflowEngine();

View file

@ -140,6 +140,19 @@ export interface Company {
competitorID?: number;
}
export interface TicketNote {
id: number;
ticketID: number;
title?: string;
description?: string;
noteType?: number;
publish?: number;
creatorResourceID?: number;
creatorType?: number;
lastActivityDate?: string;
createDateTime?: string;
}
export interface ConfigurationItem {
id: number;
companyID: number;

View file

@ -435,6 +435,13 @@ export interface SyncHistoryRecord {
triggered_by?: string | null;
}
// Device Lifecycle Policy entity
export interface DeviceLifecyclePolicy extends AuditFields {
id: number;
device_type: string;
expected_months: number;
}
// Time Entry entity
export interface TimeEntry extends AuditFields {
id: number;
@ -492,9 +499,9 @@ export type Entity =
| IssueType
| SubIssueType
| WorkType
| TimeEntry;
| TimeEntry
| DeviceLifecyclePolicy;
// Table name type
export type TableName =
| 'companies'
| 'resources'
@ -510,4 +517,5 @@ export type TableName =
| 'sub_issue_types'
| 'work_types'
| 'time_entries'
| 'sync_history';
| 'sync_history'
| 'device_lifecycle_policies';

View file

@ -62,28 +62,72 @@ export interface DattoRMMDevice {
}
export interface DattoRMMSite {
id: string;
id: number;
uid: string;
accountUid?: string;
name: string;
description: string;
notes: string;
notes: string | null;
onDemand: boolean;
splashtopAutoInstall?: boolean;
proxySettings?: {
host: string;
port: number;
username?: string;
} | null;
devicesStatus?: {
numberOfDevices: number;
numberOfOnlineDevices: number;
numberOfOfflineDevices: number;
};
autotaskCompanyName?: string;
autotaskCompanyId?: string;
portalUrl?: string;
devices?: DattoRMMDevice[];
}
export interface DattoRMMAlert {
alertUid: string;
priority: string;
diagnostics: string | null;
resolved: boolean;
resolvedBy: string | null;
resolvedOn: number | null;
muted: boolean;
ticketNumber: string | null;
timestamp: number;
alertMonitorInfo?: {
sendsEmails: boolean;
createsTicket: boolean;
};
alertContext?: {
'@class': string;
[key: string]: any;
};
alertSourceInfo?: {
deviceUid: string;
deviceName: string;
siteUid: string;
siteName: string;
};
responseActions?: Array<{
actionTime: number;
actionType: string;
description: string;
actionReference: string | null;
actionReferenceInt: string | null;
}> | null;
autoresolveMins?: number;
}
export interface DattoRMMApiResponse<T> {
items?: T[];
item?: T;
pageDetails?: {
page: number;
perPage: number;
totalPages: number;
totalItems: number;
count: number;
totalCount?: number;
prevPageUrl: string | null;
nextPageUrl: string | null;
};
}

View file

@ -14,11 +14,15 @@ export enum EntityType {
ISSUE_TYPES = 'issue_types',
SUB_ISSUE_TYPES = 'sub_issue_types',
WORK_TYPES = 'work_types',
QUEUES = 'queues',
PRIORITIES = 'priorities',
TICKET_CATEGORIES = 'ticket_categories',
BILLING_ITEMS = 'billing_items',
CONFIGURATION_ITEMS = 'configuration_items',
CONTACTS = 'contacts',
CONTRACTS = 'contracts',
TIME_ENTRIES = 'time_entries',
TICKET_NOTES = 'ticket_notes',
}
// Sync operation types
@ -151,6 +155,9 @@ export const ENTITY_DEPENDENCIES: Record<EntityType, EntityType[]> = {
[EntityType.ISSUE_TYPES]: [], // No dependencies
[EntityType.SUB_ISSUE_TYPES]: [], // No dependencies
[EntityType.WORK_TYPES]: [], // No dependencies
[EntityType.QUEUES]: [], // No dependencies
[EntityType.PRIORITIES]: [], // No dependencies
[EntityType.TICKET_CATEGORIES]: [], // No dependencies
[EntityType.CONTACTS]: [EntityType.COMPANIES], // Depends on companies
[EntityType.PROJECTS]: [EntityType.COMPANIES, EntityType.RESOURCES], // Depends on companies and resources
[EntityType.TICKETS]: [EntityType.COMPANIES, EntityType.RESOURCES, EntityType.CONTACTS], // Depends on companies, resources, contacts
@ -159,6 +166,7 @@ export const ENTITY_DEPENDENCIES: Record<EntityType, EntityType[]> = {
[EntityType.CONTRACTS]: [EntityType.COMPANIES, EntityType.CONTACTS], // Depends on companies and contacts
[EntityType.BILLING_ITEMS]: [EntityType.COMPANIES, EntityType.TASKS, EntityType.TICKETS, EntityType.PROJECTS], // Depends on multiple entities
[EntityType.TIME_ENTRIES]: [EntityType.COMPANIES, EntityType.RESOURCES, EntityType.CONTACTS, EntityType.PROJECTS, EntityType.TASKS, EntityType.TICKETS], // Depends on many entities
[EntityType.TICKET_NOTES]: [EntityType.TICKETS], // Depends on tickets
};
// Autotask API field names (for incremental sync)

View file

@ -367,3 +367,57 @@ export interface VeeamComplianceSummary {
backedUpNotContracted: number;
computedAt: Date | null;
}
// ============================================================================
// New entity types: Backup Agents + Alarms
// ============================================================================
export interface VspcBackupAgent {
instanceUid: string;
organizationUid: string;
siteUid?: string;
managementAgentUid?: string;
name: string;
agentPlatform: string;
status: string;
managementAgentStatus: string;
operationMode: string;
guiMode?: string;
platform?: string;
version?: string;
versionStatus?: string;
managementMode?: string;
installationType?: string;
activationTime?: string;
totalJobsCount: number;
runningJobsCount: number;
successJobsCount: number;
}
export interface VspcAlarmActivation {
instanceUid: string;
time: string;
status: string;
message: string;
remark?: string;
}
export interface VspcAlarmObject {
instanceUid: string;
type: string;
organizationUid?: string;
locationUid?: string;
managementAgentUid?: string;
computerName?: string;
objectUid?: string;
objectName?: string;
}
export interface VspcAlarm {
instanceUid: string;
alarmTemplateUid: string;
repeatCount: number;
object: VspcAlarmObject;
lastActivation: VspcAlarmActivation;
area?: string;
}

View file

@ -18,6 +18,7 @@ export enum WebhookEventType {
export enum WebhookEntityType {
COMPANIES = 'Companies',
TICKETS = 'Tickets',
TICKET_NOTES = 'TicketNotes',
TASKS = 'Tasks',
PROJECTS = 'Projects',
TIME_ENTRIES = 'TimeEntries',
@ -27,43 +28,125 @@ export enum WebhookEntityType {
}
/**
* Autotask webhook payload structure
* Autotask entities that support webhooks via their REST API
*/
export const WEBHOOK_SUPPORTED_ENTITIES: WebhookEntityType[] = [
WebhookEntityType.COMPANIES,
WebhookEntityType.CONTACTS,
WebhookEntityType.CONFIGURATION_ITEMS,
WebhookEntityType.TICKETS,
WebhookEntityType.TICKET_NOTES,
];
/**
* Autotask webhook registration request
*/
export interface AutotaskWebhookRegistration {
IsActive: boolean;
DeactivationUrl: string;
IsSubscribedToCreateEvents?: boolean;
IsSubscribedToUpdateEvents?: boolean;
IsSubscribedToDeleteEvents?: boolean;
Name: string;
SecretKey: string;
SendThresholdExceededNotification: boolean;
WebhookUrl: string;
NotificationEmailAddress: string;
}
/**
* Autotask webhook field trigger
*/
export interface AutotaskWebhookFieldTrigger {
FieldID: number;
IsDisplayAlwaysField: boolean;
IsSubscribedField: boolean;
WebhookID: number;
}
/**
* Autotask webhook registration result
*/
export interface AutotaskWebhookRegistrationResult {
entityType: WebhookEntityType;
webhookId: number;
fieldsRegistered: number;
excludedResources: number;
success: boolean;
error?: string;
}
/**
* Raw Autotask webhook payload as actually sent by Autotask
*/
export interface AutotaskRawWebhookPayload {
Action: string; // "Create", "Update", "Delete"
Guid: string; // Unique event GUID
EntityType: string; // Singular: "Ticket", "Company", "Contact", "ConfigurationItem", "TicketNote"
Id: number; // Entity ID
Fields?: Record<string, string>; // Changed/created fields
EventTime: string; // ISO timestamp
SequenceNumber?: number;
PersonId?: number; // Resource who triggered the event
}
/**
* Normalized webhook payload used internally
*/
export interface AutotaskWebhookPayload {
/**
* Unique identifier for the webhook event
*/
eventId: string;
/**
* Type of event (create, update, delete)
*/
eventType: WebhookEventType;
/**
* Entity type that triggered the webhook
*/
entityType: WebhookEntityType;
/**
* ID of the entity that changed
*/
entityId: number;
/**
* Timestamp when the event occurred
*/
eventTimestamp: string;
/**
* Optional: Full entity data (if configured in webhook)
*/
fields?: Record<string, string>;
entity?: Record<string, any>;
personId?: number;
}
/**
* Optional: Previous values for update events
/**
* Map Autotask singular EntityType to our WebhookEntityType enum
*/
previousValues?: Record<string, any>;
const ENTITY_TYPE_MAP: Record<string, WebhookEntityType> = {
'Company': WebhookEntityType.COMPANIES,
'Contact': WebhookEntityType.CONTACTS,
'ConfigurationItem': WebhookEntityType.CONFIGURATION_ITEMS,
'Ticket': WebhookEntityType.TICKETS,
'TicketNote': WebhookEntityType.TICKET_NOTES,
};
/**
* Map Autotask Action string to our WebhookEventType enum
*/
const ACTION_MAP: Record<string, WebhookEventType> = {
'Create': WebhookEventType.CREATE,
'Update': WebhookEventType.UPDATE,
'Delete': WebhookEventType.DELETE,
};
/**
* Normalize a raw Autotask webhook payload into our internal format
*/
export function normalizeWebhookPayload(raw: AutotaskRawWebhookPayload): AutotaskWebhookPayload {
const entityType = ENTITY_TYPE_MAP[raw.EntityType];
if (!entityType) {
throw new Error(`Unknown Autotask EntityType: ${raw.EntityType}`);
}
const eventType = ACTION_MAP[raw.Action];
if (!eventType) {
throw new Error(`Unknown Autotask Action: ${raw.Action}`);
}
return {
eventId: raw.Guid,
eventType,
entityType,
entityId: raw.Id,
eventTimestamp: raw.EventTime,
fields: raw.Fields,
personId: raw.PersonId,
};
}
/**

427
lib/types/workflow.ts Normal file
View file

@ -0,0 +1,427 @@
/**
* Workflow Engine Types
* TypeScript definitions for the ticket triage workflow engine
*/
// ============================================================================
// Enums & Constants
// ============================================================================
export type RuleType =
| 'branch_routing'
| 'ticket_type'
| 'issue_classification'
| 'priority'
| 'queue_routing';
export type MatchField =
| 'title'
| 'description'
| 'title_or_description'
| 'ticket_category'
| 'policy_name'
| 'device_name'
| 'creator_resource_id'
| 'priority'
| 'ticket_type'
| 'person_id'
| 'company_id';
export type MatchOperator =
| 'contains'
| 'starts_with'
| 'regex'
| 'equals'
| 'in'
| 'not_in';
export type ConditionOperator =
| 'equals'
| 'not_equals'
| 'in'
| 'not_in'
| 'contains'
| 'not_contains'
| 'regex'
| 'gt'
| 'lt'
| 'is_null'
| 'is_not_null';
export type ConfidenceLevel = 'high' | 'medium' | 'low';
export type Branch = 'service_desk' | 'noc' | 'soc';
export type ClassificationMethod = 'robotic' | 'ai' | 'hybrid';
export type ExecutionStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
export type StepMethod = 'robotic' | 'ai' | 'skipped';
export type ActionType =
| 'set_field'
| 'classify'
| 'ai_enhance'
| 'create_note'
| 'update_autotask'
| 'delay'
| 'skip';
export type PromptPurpose =
| 'title_cleanup'
| 'description_rewrite'
| 'ambiguous_classification'
| 'troubleshooting_steps'
| 'noc_format'
| 'soc_analysis';
export type TriggerEvent = 'ticket.created' | 'ticket.updated';
export type StepName =
| 'filter'
| 'branch_routing'
| 'ticket_type'
| 'issue_classification'
| 'priority'
| 'queue_routing'
| 'validation'
| 'ai_title'
| 'ai_description'
| 'ai_classification'
| 'ai_troubleshooting'
| 'autotask_update'
| 'create_note';
// ============================================================================
// Database Row Types
// ============================================================================
export interface ClassificationRule {
id: number;
name: string;
description: string | null;
rule_type: RuleType;
sort_order: number;
is_active: boolean;
match_field: MatchField;
match_operator: MatchOperator;
match_value: any; // JSONB: string | string[] | regex pattern
match_case_sensitive: boolean;
result_field: string;
result_value: any; // JSONB
result_field_2: string | null;
result_value_2: any | null;
confidence: ConfidenceLevel;
stop_on_match: boolean;
created_at: Date;
updated_at: Date;
}
export interface WorkflowRule {
id: number;
name: string;
description: string | null;
is_active: boolean;
sort_order: number;
trigger_event: TriggerEvent;
trigger_entity: string;
stop_processing: boolean;
created_at: Date;
updated_at: Date;
}
export interface WorkflowCondition {
id: number;
rule_id: number;
condition_group: number;
field: string;
operator: ConditionOperator;
value: any; // JSONB
created_at: Date;
}
export interface WorkflowAction {
id: number;
rule_id: number;
sort_order: number;
action_type: ActionType;
config: Record<string, any>;
created_at: Date;
}
export interface AiPromptTemplate {
id: number;
name: string;
purpose: PromptPurpose;
system_prompt: string;
user_prompt_template: string;
provider: string;
model: string;
temperature: number;
max_tokens: number;
is_active: boolean;
version: number;
created_at: Date;
updated_at: Date;
}
export interface WorkflowExecution {
id: number;
trigger_event: string;
entity_type: string;
entity_id: number;
ticket_number: string | null;
status: ExecutionStatus;
classification_method: ClassificationMethod | null;
branch: Branch | null;
started_at: Date;
completed_at: Date | null;
duration_ms: number | null;
error_message: string | null;
created_at: Date;
}
export interface WorkflowExecutionStep {
id: number;
execution_id: number;
step_name: StepName;
step_order: number;
status: ExecutionStatus;
method: StepMethod | null;
input_data: Record<string, any> | null;
output_data: Record<string, any> | null;
field_changes: Record<string, { before: any; after: any }> | null;
classification_rule_id: number | null;
confidence: ConfidenceLevel | null;
ai_request: Record<string, any> | null;
ai_response: string | null;
attempt_number: number;
error_message: string | null;
duration_ms: number | null;
started_at: Date | null;
completed_at: Date | null;
}
export interface WorkflowSetting {
key: string;
value: any; // JSONB
description: string | null;
updated_at: Date;
}
// ============================================================================
// Runtime Types (used during processing)
// ============================================================================
/** Ticket data as consumed by the workflow engine */
export interface TicketData {
id: number;
ticket_number: string | null;
title: string;
description: string | null;
ticket_category: number | null;
ticket_type: number | null;
priority: number | null;
queue_id: number | null;
issue_type: number | null;
sub_issue_type: number | null;
company_id: number;
contact_id: number | null;
assigned_resource_id: number | null;
creator_resource_id: number | null;
person_id: number | null;
status: number | null;
source: number | null;
// Computed/extracted fields for classification
policy_name?: string | null;
device_name?: string | null;
}
/** Result from a single classification step */
export interface ClassificationStepResult {
field: string;
value: any;
field_2?: string;
value_2?: any;
confidence: ConfidenceLevel;
matched_rule_id: number | null;
matched_rule_name: string | null;
method: StepMethod;
}
/** Full classification result from robotic classifier */
export interface ClassificationResult {
branch: ClassificationStepResult | null;
ticket_type: ClassificationStepResult | null;
issue_classification: ClassificationStepResult | null;
priority: ClassificationStepResult | null;
queue: ClassificationStepResult | null;
overall_confidence: ConfidenceLevel;
needs_ai: boolean;
ai_reasons: string[];
}
/** Validation result */
export interface ValidationResult {
is_valid: boolean;
errors: ValidationError[];
}
export interface ValidationError {
field: string;
message: string;
value: any;
}
/** AI enhancement result */
export interface AiEnhancementResult {
title?: string;
description?: string;
classification?: {
issue_type?: number;
sub_issue_type?: number;
ticket_type?: number;
priority?: number;
};
troubleshooting_steps?: string;
method: 'ai';
}
/** Field changes to write back to Autotask */
export interface FieldChanges {
[field: string]: {
before: any;
after: any;
};
}
/** Full execution result */
export interface ExecutionResult {
execution_id: number;
status: ExecutionStatus;
classification_method: ClassificationMethod;
branch: Branch;
field_changes: FieldChanges;
steps: ExecutionStepSummary[];
duration_ms: number;
error?: string;
}
export interface ExecutionStepSummary {
step_name: StepName;
status: ExecutionStatus;
method: StepMethod;
confidence?: ConfidenceLevel;
matched_rule?: string;
duration_ms: number;
}
/** Workflow event that triggers processing */
export interface WorkflowEvent {
trigger_event: TriggerEvent;
entity_type: string;
entity_id: number;
ticket_number?: string;
ticket_data?: TicketData;
}
/** Workflow settings loaded into memory */
export interface WorkflowSettings {
workflow_engine_enabled: boolean;
default_ai_provider: 'openai' | 'anthropic';
openai_api_key: string;
openai_model: string;
anthropic_api_key: string;
anthropic_model: string;
ai_for_title_cleanup: boolean;
ai_for_description_rewrite: boolean;
ai_for_ambiguous_classification: boolean;
ai_for_troubleshooting: boolean;
autotask_update_delay_ms: number;
max_ai_retries: number;
classification_confidence_threshold: ConfidenceLevel;
log_retention_days: number;
}
// ============================================================================
// API Types (request/response shapes for API routes)
// ============================================================================
export interface ClassificationRuleInput {
name: string;
description?: string;
rule_type: RuleType;
sort_order?: number;
is_active?: boolean;
match_field: MatchField;
match_operator: MatchOperator;
match_value: any;
match_case_sensitive?: boolean;
result_field: string;
result_value: any;
result_field_2?: string;
result_value_2?: any;
confidence?: ConfidenceLevel;
stop_on_match?: boolean;
}
export interface WorkflowRuleInput {
name: string;
description?: string;
is_active?: boolean;
sort_order?: number;
trigger_event: TriggerEvent;
trigger_entity?: string;
stop_processing?: boolean;
conditions: WorkflowConditionInput[];
actions: WorkflowActionInput[];
}
export interface WorkflowConditionInput {
condition_group?: number;
field: string;
operator: ConditionOperator;
value: any;
}
export interface WorkflowActionInput {
sort_order?: number;
action_type: ActionType;
config?: Record<string, any>;
}
export interface AiPromptTemplateInput {
name: string;
purpose: PromptPurpose;
system_prompt: string;
user_prompt_template: string;
provider?: string;
model?: string;
temperature?: number;
max_tokens?: number;
is_active?: boolean;
}
export interface WorkflowTestRequest {
ticket_id: number;
dry_run?: boolean; // default true
}
export interface WorkflowTestResult {
ticket: TicketData;
classification: ClassificationResult;
validation: ValidationResult;
proposed_changes: FieldChanges;
execution_steps: ExecutionStepSummary[];
}
/** Workflow rule with its conditions and actions loaded */
export interface WorkflowRuleWithDetails extends WorkflowRule {
conditions: WorkflowCondition[];
actions: WorkflowAction[];
}
/** Execution with its steps loaded */
export interface WorkflowExecutionWithSteps extends WorkflowExecution {
steps: WorkflowExecutionStep[];
}

View file

@ -57,6 +57,9 @@ export function mapAutotaskToDatabase(
case EntityType.TIME_ENTRIES:
mapped = mapTimeEntry(data);
break;
case EntityType.TICKET_NOTES:
mapped = mapTicketNote(data);
break;
case EntityType.STATUSES:
case EntityType.ISSUE_TYPES:
case EntityType.SUB_ISSUE_TYPES:
@ -201,6 +204,26 @@ function mapTicket(data: any): Record<string, any> {
};
}
/**
* Map TicketNote entity
*/
function mapTicketNote(data: any): Record<string, any> {
return {
id: data.id,
ticket_id: data.ticketID,
title: data.title,
description: data.description,
note_type: data.noteType,
publish: data.publish,
creator_resource_id: data.creatorResourceID,
creator_type: data.creatorType,
last_activity_date: data.lastActivityDate,
create_date_time: data.createDateTime,
synced_at: data.synced_at,
is_deleted: data.is_deleted || false,
};
}
/**
* Map Task entity
*/

View file

@ -56,6 +56,9 @@ export function getAllEntitiesInOrder(): EntityType[] {
EntityType.ISSUE_TYPES,
EntityType.SUB_ISSUE_TYPES,
EntityType.WORK_TYPES,
EntityType.QUEUES,
EntityType.PRIORITIES,
EntityType.TICKET_CATEGORIES,
EntityType.CONTACTS,
EntityType.PROJECTS,
EntityType.TICKETS,
@ -92,11 +95,15 @@ export function getAutotaskEntityName(entity: EntityType): string {
[EntityType.ISSUE_TYPES]: 'IssueTypes',
[EntityType.SUB_ISSUE_TYPES]: 'SubIssueTypes',
[EntityType.WORK_TYPES]: 'WorkTypes',
[EntityType.QUEUES]: 'Queues',
[EntityType.PRIORITIES]: 'Priorities',
[EntityType.TICKET_CATEGORIES]: 'TicketCategories',
[EntityType.BILLING_ITEMS]: 'BillingItems',
[EntityType.CONFIGURATION_ITEMS]: 'ConfigurationItems',
[EntityType.CONTACTS]: 'Contacts',
[EntityType.CONTRACTS]: 'Contracts',
[EntityType.TIME_ENTRIES]: 'TimeEntries',
[EntityType.TICKET_NOTES]: 'TicketNotes',
};
return mapping[entity] || entity;
@ -113,6 +120,9 @@ export function isPicklistEntity(entity: EntityType): boolean {
EntityType.ISSUE_TYPES,
EntityType.SUB_ISSUE_TYPES,
EntityType.WORK_TYPES,
EntityType.QUEUES,
EntityType.PRIORITIES,
EntityType.TICKET_CATEGORIES,
].includes(entity);
}
@ -133,10 +143,14 @@ export function getLastModifiedField(entity: EntityType): string {
[EntityType.CONTRACTS]: 'lastModifiedDateTime',
[EntityType.BILLING_ITEMS]: 'itemDate',
[EntityType.TIME_ENTRIES]: 'dateWorked',
[EntityType.TICKET_NOTES]: 'lastActivityDate',
[EntityType.STATUSES]: 'lastModifiedDate',
[EntityType.ISSUE_TYPES]: 'lastModifiedDate',
[EntityType.SUB_ISSUE_TYPES]: 'lastModifiedDate',
[EntityType.WORK_TYPES]: 'lastModifiedDate',
[EntityType.QUEUES]: 'lastModifiedDate',
[EntityType.PRIORITIES]: 'lastModifiedDate',
[EntityType.TICKET_CATEGORIES]: 'lastModifiedDate',
};
return mapping[entity] || 'lastModifiedDate';
@ -159,10 +173,14 @@ export function getActiveField(entity: EntityType): string | null {
[EntityType.CONTRACTS]: null, // Use status field instead
[EntityType.BILLING_ITEMS]: null,
[EntityType.TIME_ENTRIES]: null, // Time entries don't have active status
[EntityType.TICKET_NOTES]: null, // Ticket notes don't have active status
[EntityType.STATUSES]: 'isActive',
[EntityType.ISSUE_TYPES]: 'isActive',
[EntityType.SUB_ISSUE_TYPES]: 'isActive',
[EntityType.WORK_TYPES]: 'isActive',
[EntityType.QUEUES]: 'isActive',
[EntityType.PRIORITIES]: 'isActive',
[EntityType.TICKET_CATEGORIES]: 'isActive',
};
return mapping[entity] || null;
@ -245,10 +263,14 @@ export function buildDateRangeFilter(
[EntityType.RESOURCES]: null,
[EntityType.CONTACTS]: null,
[EntityType.CONFIGURATION_ITEMS]: null,
[EntityType.TICKET_NOTES]: null,
[EntityType.STATUSES]: null,
[EntityType.ISSUE_TYPES]: null,
[EntityType.SUB_ISSUE_TYPES]: null,
[EntityType.WORK_TYPES]: null,
[EntityType.QUEUES]: null,
[EntityType.PRIORITIES]: null,
[EntityType.TICKET_CATEGORIES]: null,
};
const dateField = dateFieldMapping[entity];
@ -427,11 +449,15 @@ export function getEntityDisplayName(entity: EntityType): string {
[EntityType.ISSUE_TYPES]: 'Issue Types',
[EntityType.SUB_ISSUE_TYPES]: 'Sub-Issue Types',
[EntityType.WORK_TYPES]: 'Work Types',
[EntityType.QUEUES]: 'Queues',
[EntityType.PRIORITIES]: 'Priorities',
[EntityType.TICKET_CATEGORIES]: 'Ticket Categories',
[EntityType.BILLING_ITEMS]: 'Billing Items',
[EntityType.CONFIGURATION_ITEMS]: 'Configuration Items',
[EntityType.CONTACTS]: 'Contacts',
[EntityType.CONTRACTS]: 'Contracts',
[EntityType.TIME_ENTRIES]: 'Time Entries',
[EntityType.TICKET_NOTES]: 'Ticket Notes',
};
return mapping[entity] || entity;

View file

@ -0,0 +1,48 @@
-- Migration: Create ticket_notes table
-- Description: Stores Autotask ticket notes synced via webhooks
CREATE TABLE IF NOT EXISTS ticket_notes (
id BIGINT PRIMARY KEY,
ticket_id BIGINT NOT NULL,
title VARCHAR(500),
description TEXT,
note_type INTEGER, -- Autotask noteType picklist
publish INTEGER, -- Visibility: 1=All, 2=Internal, etc.
creator_resource_id BIGINT,
creator_type INTEGER, -- 1=Resource, 2=Contact, etc.
last_activity_date TIMESTAMP WITH TIME ZONE,
create_date_time TIMESTAMP WITH TIME ZONE,
is_deleted BOOLEAN DEFAULT false,
synced_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_ticket_notes_ticket_id ON ticket_notes(ticket_id);
CREATE INDEX IF NOT EXISTS idx_ticket_notes_creator ON ticket_notes(creator_resource_id);
CREATE INDEX IF NOT EXISTS idx_ticket_notes_create_date ON ticket_notes(create_date_time DESC);
CREATE INDEX IF NOT EXISTS idx_ticket_notes_is_deleted ON ticket_notes(is_deleted);
-- Updated_at trigger
CREATE OR REPLACE FUNCTION update_ticket_notes_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS ticket_notes_updated_at ON ticket_notes;
CREATE TRIGGER ticket_notes_updated_at
BEFORE UPDATE ON ticket_notes
FOR EACH ROW
EXECUTE FUNCTION update_ticket_notes_updated_at();
COMMENT ON TABLE ticket_notes IS 'Autotask ticket notes synced via webhooks';
-- Ensure webhook_configs has rows for all webhook-supported entities
INSERT INTO webhook_configs (entity_type, event_types, is_active) VALUES
('TicketNotes', '["create", "update", "delete"]', true),
('ConfigurationItems', '["create", "update", "delete"]', true)
ON CONFLICT (entity_type) DO NOTHING;

View file

@ -0,0 +1,16 @@
-- Queues picklist table (synced from Autotask Ticket.queueID field)
CREATE TABLE IF NOT EXISTS queues (
value INTEGER PRIMARY KEY,
label VARCHAR(200) NOT NULL,
is_active BOOLEAN DEFAULT true,
is_system BOOLEAN DEFAULT false,
sort_order INTEGER,
parent_value INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted BOOLEAN DEFAULT false,
deleted_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_queues_is_active ON queues (is_active);

View file

@ -0,0 +1,102 @@
-- Datto RMM sync tables: sites, devices, alerts
-- Sites map to Autotask companies via autotask_company_id
CREATE TABLE IF NOT EXISTS datto_rmm_sites (
id INTEGER PRIMARY KEY,
uid TEXT UNIQUE NOT NULL,
account_uid TEXT,
name TEXT NOT NULL,
description TEXT,
notes TEXT,
on_demand BOOLEAN DEFAULT false,
autotask_company_id INTEGER REFERENCES companies(id),
autotask_company_name TEXT,
number_of_devices INTEGER DEFAULT 0,
number_of_online_devices INTEGER DEFAULT 0,
number_of_offline_devices INTEGER DEFAULT 0,
portal_url TEXT,
synced_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS datto_rmm_devices (
id INTEGER PRIMARY KEY,
uid TEXT UNIQUE NOT NULL,
site_id INTEGER REFERENCES datto_rmm_sites(id),
site_uid TEXT,
site_name TEXT,
hostname TEXT,
description TEXT,
device_type_category TEXT,
device_type TEXT,
operating_system TEXT,
domain TEXT,
int_ip_address TEXT,
ext_ip_address TEXT,
last_logged_in_user TEXT,
last_seen TIMESTAMPTZ,
last_reboot TIMESTAMPTZ,
last_audit_date TIMESTAMPTZ,
creation_date TIMESTAMPTZ,
online BOOLEAN DEFAULT false,
suspended BOOLEAN DEFAULT false,
deleted BOOLEAN DEFAULT false,
reboot_required BOOLEAN DEFAULT false,
a64_bit BOOLEAN DEFAULT true,
cag_version TEXT,
display_version TEXT,
antivirus_product TEXT,
antivirus_status TEXT,
patch_status TEXT,
patches_approved_pending INTEGER DEFAULT 0,
patches_not_approved INTEGER DEFAULT 0,
patches_installed INTEGER DEFAULT 0,
software_status TEXT,
portal_url TEXT,
web_remote_url TEXT,
warranty_date TIMESTAMPTZ,
snmp_enabled BOOLEAN DEFAULT false,
device_class TEXT,
network_probe BOOLEAN DEFAULT false,
udf JSONB,
synced_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS datto_rmm_alerts (
alert_uid TEXT PRIMARY KEY,
device_uid TEXT,
device_name TEXT,
site_uid TEXT,
site_name TEXT,
priority TEXT,
alert_context JSONB,
alert_monitor_info JSONB,
diagnostics TEXT,
resolved BOOLEAN DEFAULT false,
resolved_by TEXT,
resolved_on TIMESTAMPTZ,
muted BOOLEAN DEFAULT false,
ticket_number TEXT,
autoresolve_mins INTEGER,
response_actions JSONB,
timestamp TIMESTAMPTZ NOT NULL,
synced_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_datto_rmm_sites_uid ON datto_rmm_sites(uid);
CREATE INDEX IF NOT EXISTS idx_datto_rmm_sites_company ON datto_rmm_sites(autotask_company_id);
CREATE INDEX IF NOT EXISTS idx_datto_rmm_devices_uid ON datto_rmm_devices(uid);
CREATE INDEX IF NOT EXISTS idx_datto_rmm_devices_site ON datto_rmm_devices(site_id);
CREATE INDEX IF NOT EXISTS idx_datto_rmm_devices_hostname ON datto_rmm_devices(hostname);
CREATE INDEX IF NOT EXISTS idx_datto_rmm_devices_online ON datto_rmm_devices(online);
CREATE INDEX IF NOT EXISTS idx_datto_rmm_alerts_device ON datto_rmm_alerts(device_uid);
CREATE INDEX IF NOT EXISTS idx_datto_rmm_alerts_site ON datto_rmm_alerts(site_uid);
CREATE INDEX IF NOT EXISTS idx_datto_rmm_alerts_resolved ON datto_rmm_alerts(resolved);
CREATE INDEX IF NOT EXISTS idx_datto_rmm_alerts_timestamp ON datto_rmm_alerts(timestamp);
CREATE INDEX IF NOT EXISTS idx_datto_rmm_alerts_ticket ON datto_rmm_alerts(ticket_number);

View file

@ -0,0 +1,20 @@
-- Migration to create device lifecycle policies table
CREATE TABLE IF NOT EXISTS device_lifecycle_policies (
id SERIAL PRIMARY KEY,
device_type VARCHAR(255) NOT NULL UNIQUE,
expected_months INTEGER NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE,
deleted_at TIMESTAMP WITH TIME ZONE
);
-- Seed some default policies based on common MSP standards
INSERT INTO device_lifecycle_policies (device_type, expected_months) VALUES
('Workstation', 48), -- 4 years
('Desktop', 60), -- 5 years
('Laptop', 48), -- 4 years
('Server', 60), -- 5 years
('Network', 60), -- 5 years
('Firewall', 60) -- 5 years
ON CONFLICT (device_type) DO NOTHING;

View file

@ -0,0 +1,62 @@
-- Veeam Backup Agents (agent installs on managed machines)
CREATE TABLE IF NOT EXISTS veeam_backup_agents (
instance_uid VARCHAR(255) PRIMARY KEY,
organization_uid VARCHAR(255) REFERENCES veeam_organizations(instance_uid) ON DELETE SET NULL,
site_uid VARCHAR(255),
management_agent_uid VARCHAR(255),
name VARCHAR(500) NOT NULL,
agent_platform VARCHAR(50),
status VARCHAR(50),
management_agent_status VARCHAR(50),
operation_mode VARCHAR(50),
gui_mode VARCHAR(50),
platform VARCHAR(50),
version VARCHAR(100),
version_status VARCHAR(50),
management_mode VARCHAR(100),
installation_type VARCHAR(50),
activation_time TIMESTAMPTZ,
total_jobs_count INTEGER DEFAULT 0,
running_jobs_count INTEGER DEFAULT 0,
success_jobs_count INTEGER DEFAULT 0,
synced_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Veeam VSPC Alarms (platform-level alarms, distinct from job failures)
CREATE TABLE IF NOT EXISTS veeam_alarms (
instance_uid VARCHAR(255) PRIMARY KEY,
alarm_template_uid VARCHAR(255),
repeat_count INTEGER DEFAULT 0,
object_uid VARCHAR(255),
object_type VARCHAR(100),
object_name VARCHAR(500),
object_computer_name VARCHAR(500),
organization_uid VARCHAR(255) REFERENCES veeam_organizations(instance_uid) ON DELETE SET NULL,
location_uid VARCHAR(255),
management_agent_uid VARCHAR(255),
last_activation_uid VARCHAR(255),
last_activation_time TIMESTAMPTZ,
last_activation_status VARCHAR(50),
last_activation_message TEXT,
last_activation_remark TEXT,
area VARCHAR(50),
resolved BOOLEAN DEFAULT false,
synced_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_veeam_agents_org ON veeam_backup_agents(organization_uid);
CREATE INDEX IF NOT EXISTS idx_veeam_agents_status ON veeam_backup_agents(status);
CREATE INDEX IF NOT EXISTS idx_veeam_agents_mgmt_status ON veeam_backup_agents(management_agent_status);
CREATE INDEX IF NOT EXISTS idx_veeam_agents_platform ON veeam_backup_agents(agent_platform);
CREATE INDEX IF NOT EXISTS idx_veeam_agents_synced ON veeam_backup_agents(synced_at);
CREATE INDEX IF NOT EXISTS idx_veeam_alarms_org ON veeam_alarms(organization_uid);
CREATE INDEX IF NOT EXISTS idx_veeam_alarms_status ON veeam_alarms(last_activation_status);
CREATE INDEX IF NOT EXISTS idx_veeam_alarms_resolved ON veeam_alarms(resolved);
CREATE INDEX IF NOT EXISTS idx_veeam_alarms_time ON veeam_alarms(last_activation_time);
CREATE INDEX IF NOT EXISTS idx_veeam_alarms_synced ON veeam_alarms(synced_at);

View file

@ -0,0 +1,24 @@
-- Priorities picklist table (synced from Autotask Ticket.priority field)
CREATE TABLE IF NOT EXISTS priorities (
value INTEGER PRIMARY KEY,
label VARCHAR(200) NOT NULL,
is_active BOOLEAN DEFAULT true,
sort_order INTEGER DEFAULT 0,
synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Ticket Categories picklist table (synced from Autotask Ticket.ticketCategory field)
CREATE TABLE IF NOT EXISTS ticket_categories (
value INTEGER PRIMARY KEY,
label VARCHAR(200) NOT NULL,
is_active BOOLEAN DEFAULT true,
sort_order INTEGER DEFAULT 0,
synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_priorities_active ON priorities(is_active);
CREATE INDEX IF NOT EXISTS idx_ticket_categories_active ON ticket_categories(is_active);

View file

@ -0,0 +1,349 @@
-- Workflow Engine Tables
-- Supports robotic-first ticket triage with configurable rules and AI fallback
-- Classification rules: DB-driven keyword→classification mappings
-- Replaces hardcoded keyword lists from n8n AI prompts
CREATE TABLE IF NOT EXISTS classification_rules (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
rule_type VARCHAR(50) NOT NULL, -- 'branch_routing', 'ticket_type', 'issue_classification', 'priority', 'queue_routing'
sort_order INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT true,
-- Pattern matching
match_field VARCHAR(50) NOT NULL, -- 'title', 'description', 'title_or_description', 'ticket_category', 'policy_name', 'device_name', 'creator_resource_id', 'priority', 'ticket_type'
match_operator VARCHAR(50) NOT NULL, -- 'contains', 'starts_with', 'regex', 'equals', 'in', 'not_in'
match_value JSONB NOT NULL, -- string, string[], or regex pattern
match_case_sensitive BOOLEAN DEFAULT false,
-- Result: what to set when matched
result_field VARCHAR(50) NOT NULL, -- 'branch', 'ticket_type', 'issue_type', 'sub_issue_type', 'priority', 'queue_id', 'ticket_category'
result_value JSONB NOT NULL,
-- Optional secondary result (e.g., issue_type AND sub_issue_type together)
result_field_2 VARCHAR(50),
result_value_2 JSONB,
confidence VARCHAR(20) DEFAULT 'high', -- 'high', 'medium', 'low'
stop_on_match BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Workflow rules: configurable exclusion/inclusion filters
CREATE TABLE IF NOT EXISTS workflow_rules (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
is_active BOOLEAN DEFAULT true,
sort_order INTEGER DEFAULT 0,
trigger_event VARCHAR(50) NOT NULL, -- 'ticket.created', 'ticket.updated'
trigger_entity VARCHAR(50) DEFAULT 'ticket',
stop_processing BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Rule conditions (AND within group, OR between groups)
CREATE TABLE IF NOT EXISTS workflow_conditions (
id SERIAL PRIMARY KEY,
rule_id INTEGER NOT NULL REFERENCES workflow_rules(id) ON DELETE CASCADE,
condition_group INTEGER DEFAULT 0,
field VARCHAR(100) NOT NULL,
operator VARCHAR(50) NOT NULL,
value JSONB NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- Rule actions
CREATE TABLE IF NOT EXISTS workflow_actions (
id SERIAL PRIMARY KEY,
rule_id INTEGER NOT NULL REFERENCES workflow_rules(id) ON DELETE CASCADE,
sort_order INTEGER DEFAULT 0,
action_type VARCHAR(50) NOT NULL, -- 'set_field', 'classify', 'ai_enhance', 'create_note', 'update_autotask', 'delay', 'skip'
config JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW()
);
-- AI prompt templates (for cases where AI is needed)
CREATE TABLE IF NOT EXISTS ai_prompt_templates (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
purpose VARCHAR(50) NOT NULL, -- 'title_cleanup', 'description_rewrite', 'ambiguous_classification', 'troubleshooting_steps', 'noc_format', 'soc_analysis'
system_prompt TEXT NOT NULL,
user_prompt_template TEXT NOT NULL,
provider VARCHAR(50) DEFAULT 'openai',
model VARCHAR(100) DEFAULT 'gpt-4o',
temperature DECIMAL(3,2) DEFAULT 0.3,
max_tokens INTEGER DEFAULT 4000,
is_active BOOLEAN DEFAULT true,
version INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Workflow execution log
CREATE TABLE IF NOT EXISTS workflow_executions (
id SERIAL PRIMARY KEY,
trigger_event VARCHAR(50) NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id BIGINT NOT NULL,
ticket_number VARCHAR(50),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
classification_method VARCHAR(20), -- 'robotic', 'ai', 'hybrid'
branch VARCHAR(20), -- 'service_desk', 'noc', 'soc'
started_at TIMESTAMP DEFAULT NOW(),
completed_at TIMESTAMP,
duration_ms INTEGER,
error_message TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- Execution step details
CREATE TABLE IF NOT EXISTS workflow_execution_steps (
id SERIAL PRIMARY KEY,
execution_id INTEGER NOT NULL REFERENCES workflow_executions(id) ON DELETE CASCADE,
step_name VARCHAR(100) NOT NULL,
step_order INTEGER NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
method VARCHAR(20), -- 'robotic', 'ai', 'skipped'
input_data JSONB,
output_data JSONB,
field_changes JSONB,
classification_rule_id INTEGER,
confidence VARCHAR(20),
ai_request JSONB,
ai_response TEXT,
attempt_number INTEGER DEFAULT 1,
error_message TEXT,
duration_ms INTEGER,
started_at TIMESTAMP,
completed_at TIMESTAMP
);
-- Workflow settings
CREATE TABLE IF NOT EXISTS workflow_settings (
key VARCHAR(100) PRIMARY KEY,
value JSONB NOT NULL,
description TEXT,
updated_at TIMESTAMP DEFAULT NOW()
);
INSERT INTO workflow_settings (key, value, description) VALUES
('workflow_engine_enabled', 'false', 'Master enable/disable for workflow engine'),
('default_ai_provider', '"openai"', 'Default AI provider (openai or anthropic)'),
('openai_api_key', '""', 'OpenAI API key'),
('openai_model', '"gpt-4o"', 'Default OpenAI model'),
('anthropic_api_key', '""', 'Anthropic API key'),
('anthropic_model', '"claude-sonnet-4-20250514"', 'Default Anthropic model'),
('ai_for_title_cleanup', 'true', 'Use AI to clean up messy ticket titles'),
('ai_for_description_rewrite', 'true', 'Use AI to restructure unstructured descriptions'),
('ai_for_ambiguous_classification', 'true', 'Use AI when robotic classifier has no match'),
('ai_for_troubleshooting', 'true', 'Generate AI troubleshooting steps for incidents'),
('autotask_update_delay_ms', '30000', 'Delay before writing back to Autotask (ms)'),
('max_ai_retries', '2', 'Max AI retry attempts on validation failure'),
('classification_confidence_threshold', '"medium"', 'Min confidence to skip AI (high, medium, low)'),
('log_retention_days', '90', 'Days to retain execution logs')
ON CONFLICT (key) DO NOTHING;
-- Indexes
CREATE INDEX idx_classification_rules_type ON classification_rules(rule_type, is_active, sort_order);
CREATE INDEX idx_workflow_rules_active ON workflow_rules(is_active, sort_order);
CREATE INDEX idx_workflow_conditions_rule ON workflow_conditions(rule_id);
CREATE INDEX idx_workflow_actions_rule ON workflow_actions(rule_id, sort_order);
CREATE INDEX idx_workflow_executions_entity ON workflow_executions(entity_type, entity_id);
CREATE INDEX idx_workflow_executions_status ON workflow_executions(status, created_at);
CREATE INDEX idx_workflow_executions_created ON workflow_executions(created_at);
CREATE INDEX idx_workflow_execution_steps_exec ON workflow_execution_steps(execution_id, step_order);
-- ============================================================================
-- SEED: Default workflow filter rules (port from n8n IF nodes)
-- ============================================================================
-- Rule 1: Exclude specific API users
INSERT INTO workflow_rules (name, description, is_active, sort_order, trigger_event, stop_processing) VALUES
('Exclude API Users', 'Skip tickets created by automation API users', true, 10, 'ticket.created', true);
INSERT INTO workflow_conditions (rule_id, condition_group, field, operator, value) VALUES
((SELECT id FROM workflow_rules WHERE name = 'Exclude API Users'), 0, 'person_id', 'in', '[30861575]');
INSERT INTO workflow_actions (rule_id, sort_order, action_type, config) VALUES
((SELECT id FROM workflow_rules WHERE name = 'Exclude API Users'), 0, 'skip', '{"reason": "API user exclusion"}');
-- Rule 2: Exclude specific technician creators
INSERT INTO workflow_rules (name, description, is_active, sort_order, trigger_event, stop_processing) VALUES
('Exclude Technician Creators', 'Skip tickets created by specific technicians', true, 20, 'ticket.created', true);
INSERT INTO workflow_conditions (rule_id, condition_group, field, operator, value) VALUES
((SELECT id FROM workflow_rules WHERE name = 'Exclude Technician Creators'), 0, 'creator_resource_id', 'in', '[30861471]');
INSERT INTO workflow_actions (rule_id, sort_order, action_type, config) VALUES
((SELECT id FROM workflow_rules WHERE name = 'Exclude Technician Creators'), 0, 'skip', '{"reason": "Technician creator exclusion"}');
-- Rule 3: Category filter - only process triageable categories
INSERT INTO workflow_rules (name, description, is_active, sort_order, trigger_event, stop_processing) VALUES
('Category Filter', 'Only process tickets in triageable categories (NOC, Service Desk, Triage, SOC)', true, 30, 'ticket.created', true);
INSERT INTO workflow_conditions (rule_id, condition_group, field, operator, value) VALUES
((SELECT id FROM workflow_rules WHERE name = 'Category Filter'), 0, 'ticket_category', 'not_in', '[2, 3, 159, 161]');
INSERT INTO workflow_actions (rule_id, sort_order, action_type, config) VALUES
((SELECT id FROM workflow_rules WHERE name = 'Category Filter'), 0, 'skip', '{"reason": "Ticket category not eligible for triage"}');
-- Rule 4: Company exclusions
INSERT INTO workflow_rules (name, description, is_active, sort_order, trigger_event, stop_processing) VALUES
('Company Exclusions', 'Skip tickets from excluded companies (tools-only clients)', true, 40, 'ticket.created', true);
INSERT INTO workflow_conditions (rule_id, condition_group, field, operator, value) VALUES
((SELECT id FROM workflow_rules WHERE name = 'Company Exclusions'), 0, 'company_id', 'in', '[29861409, 29783545, 29861361, 29702433]');
INSERT INTO workflow_actions (rule_id, sort_order, action_type, config) VALUES
((SELECT id FROM workflow_rules WHERE name = 'Company Exclusions'), 0, 'skip', '{"reason": "Company excluded from triage"}');
-- ============================================================================
-- SEED: Branch routing classification rules
-- ============================================================================
-- NOC branch routing
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, confidence) VALUES
('Datto RMM alerts (email)', 'branch_routing', 10, 'description', 'contains', '["alerts@rmm.datto.com", "monitor alert", "alert was triggered", "aem alert"]', 'branch', '"noc"', 'high'),
('Datto RMM alerts (title)', 'branch_routing', 20, 'title', 'contains', '["datto", "rmm"]', 'branch', '"noc"', 'high'),
('Zoom monitoring alerts', 'branch_routing', 30, 'title_or_description', 'contains', '["zoom room", "zoom monitoring", "video device disconnected", "audio device disconnected", "controller (ipad)"]', 'branch', '"noc"', 'high');
-- SOC branch routing
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, confidence) VALUES
('Phishing reports', 'branch_routing', 40, 'title', 'contains', '["phishing:", "[phish alert]"]', 'branch', '"soc"', 'high'),
('Phishing indicators (headers)', 'branch_routing', 45, 'description', 'contains', '["received-spf: fail", "dkim=fail", "dmarc=fail", "# questionable urls detected"]', 'branch', '"soc"', 'high'),
('Blumira security alerts', 'branch_routing', 50, 'title_or_description', 'contains', '["from blumira", "blumira@messages.blumira.com", "suspect |", "critical |", "informational |"]', 'branch', '"soc"', 'high'),
('Security threat keywords', 'branch_routing', 60, 'title_or_description', 'contains', '["malware detected", "ransomware", "threat detected", "suspicious activity", "security breach", "unauthorized access", "compromised account", "attack detected", "exploit", "huntress detection", "duo security alert", "brute force", "stolen credentials", "credential harvesting", "account takeover", "indicator of compromise", "security alert", "security event", "malicious link", "suspicious attachment", "incident response"]', 'branch', '"soc"', 'high');
-- Default: service_desk (handled by code when no branch_routing rule matches)
-- ============================================================================
-- SEED: Ticket type classification rules
-- ============================================================================
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, confidence) VALUES
('Incident - was working language', 'ticket_type', 10, 'title_or_description', 'contains', '["was working", "stopped working", "quit working", "suddenly stopped", "used to work", "previously worked", "it broke"]', 'ticket_type', '2', 'high'),
('Incident - error/failure language', 'ticket_type', 20, 'title_or_description', 'contains', '["not working", "won''t open", "getting error", "unable to", "failed", "can''t access", "can''t send", "can''t receive", "disconnected", "crashes", "login failure", "access denied"]', 'ticket_type', '2', 'high'),
('Service Request - request language', 'ticket_type', 30, 'title_or_description', 'contains', '["how do i", "please set up", "please install", "can i have", "need account", "new user", "new hire", "schedule", "consultation", "review my", "following up", "shipment"]', 'ticket_type', '1', 'high'),
('Service Request - setup language', 'ticket_type', 40, 'title_or_description', 'contains', '["set up", "configure", "create new", "add new", "install", "deploy", "onboarding"]', 'ticket_type', '1', 'medium');
-- ============================================================================
-- SEED: Issue classification rules (keyword → issueType + subIssueType)
-- ============================================================================
-- Category overrides (highest priority)
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, result_field_2, result_value_2, confidence) VALUES
('New User (category 127)', 'issue_classification', 1, 'ticket_category', 'equals', '127', 'issue_type', '55', 'sub_issue_type', '522', 'high'),
('User Separation (category 128)', 'issue_classification', 2, 'ticket_category', 'equals', '128', 'issue_type', '55', 'sub_issue_type', '526', 'high');
-- Email (issueType 34) - highest keyword priority per n8n rules
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, result_field_2, result_value_2, confidence) VALUES
('Email - can''t send', 'issue_classification', 10, 'title_or_description', 'contains', '["can''t send email", "unable to send email", "email not sending"]', 'issue_type', '34', 'sub_issue_type', '312', 'high'),
('Email - can''t receive', 'issue_classification', 11, 'title_or_description', 'contains', '["can''t receive email", "not receiving email", "email not coming", "can''t access email", "unable to access email", "can''t get into email"]', 'issue_type', '34', 'sub_issue_type', '313', 'high'),
('Email - spam/junk', 'issue_classification', 12, 'title_or_description', 'contains', '["spam email", "junk email", "junk mail", "too many emails"]', 'issue_type', '34', 'sub_issue_type', '314', 'high'),
('Email - phishing', 'issue_classification', 13, 'title_or_description', 'contains', '["phishing email", "phishing attempt", "suspicious email"]', 'issue_type', '34', 'sub_issue_type', '316', 'high'),
('Email - Outlook', 'issue_classification', 14, 'title_or_description', 'contains', '["outlook crash", "outlook not opening", "outlook won''t open", "outlook freezing"]', 'issue_type', '34', 'sub_issue_type', '451', 'high'),
('Email - signatures', 'issue_classification', 15, 'title_or_description', 'contains', '["email signature"]', 'issue_type', '34', 'sub_issue_type', '568', 'high'),
('Email - Mimecast release', 'issue_classification', 16, 'title_or_description', 'contains', '["mimecast release", "release email"]', 'issue_type', '34', 'sub_issue_type', '706', 'high'),
('Email - Mimecast allow', 'issue_classification', 17, 'title_or_description', 'contains', '["mimecast allow", "allow sender", "whitelist sender"]', 'issue_type', '34', 'sub_issue_type', '707', 'high'),
('Email - Mimecast block', 'issue_classification', 18, 'title_or_description', 'contains', '["mimecast block", "block sender", "blacklist sender"]', 'issue_type', '34', 'sub_issue_type', '832', 'high'),
('Email - distribution group', 'issue_classification', 19, 'title_or_description', 'contains', '["distribution group", "distribution list", "email group"]', 'issue_type', '34', 'sub_issue_type', '771', 'high'),
('Email - shared mailbox', 'issue_classification', 20, 'title_or_description', 'contains', '["shared mailbox", "shared email"]', 'issue_type', '34', 'sub_issue_type', '814', 'high'),
('Email - forwarding', 'issue_classification', 21, 'title_or_description', 'contains', '["email forward", "forward setup", "forwarding"]', 'issue_type', '34', 'sub_issue_type', '862', 'high'),
('Email - out of office', 'issue_classification', 22, 'title_or_description', 'contains', '["out of office", "auto-reply", "vacation reply"]', 'issue_type', '34', 'sub_issue_type', '839', 'high'),
('Email - mailbox full', 'issue_classification', 23, 'title_or_description', 'contains', '["mailbox full", "mailbox size", "mailbox quota"]', 'issue_type', '34', 'sub_issue_type', '838', 'high'),
('Email - generic', 'issue_classification', 29, 'title_or_description', 'contains', '["email", "e-mail", "inbox", "mailbox"]', 'issue_type', '34', 'sub_issue_type', '317', 'medium');
-- Active Directory & Accounts (issueType 76)
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, result_field_2, result_value_2, confidence) VALUES
('AD - password reset', 'issue_classification', 30, 'title_or_description', 'contains', '["password reset", "reset password", "forgot password", "password expired"]', 'issue_type', '76', 'sub_issue_type', '759', 'high'),
('AD - account lockout', 'issue_classification', 31, 'title_or_description', 'contains', '["account locked", "locked out", "account lockout"]', 'issue_type', '76', 'sub_issue_type', '795', 'high'),
('AD - DUO lockout', 'issue_classification', 32, 'title_or_description', 'contains', '["duo locked", "duo lockout", "mfa locked"]', 'issue_type', '76', 'sub_issue_type', '758', 'high'),
('AD - file permissions', 'issue_classification', 33, 'title_or_description', 'contains', '["file permission", "folder permission", "access to folder", "shared drive access"]', 'issue_type', '76', 'sub_issue_type', '760', 'high'),
('AD - drive mapping', 'issue_classification', 34, 'title_or_description', 'contains', '["drive mapping", "mapped drive", "network drive"]', 'issue_type', '76', 'sub_issue_type', '761', 'high'),
('AD - admin rights', 'issue_classification', 35, 'title_or_description', 'contains', '["admin rights", "local admin", "administrator access"]', 'issue_type', '76', 'sub_issue_type', '828', 'high'),
('AD - 365 license', 'issue_classification', 36, 'title_or_description', 'contains', '["365 license", "office license", "microsoft license"]', 'issue_type', '76', 'sub_issue_type', '829', 'high');
-- Peripheral Connectivity (issueType 51)
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, result_field_2, result_value_2, confidence) VALUES
('Peripheral - printer/printing', 'issue_classification', 40, 'title_or_description', 'contains', '["printer", "printing", "print job", "can''t print"]', 'issue_type', '51', 'sub_issue_type', '377', 'high'),
('Peripheral - scanner', 'issue_classification', 41, 'title_or_description', 'contains', '["scanner", "scanning", "can''t scan"]', 'issue_type', '51', 'sub_issue_type', '550', 'high'),
('Peripheral - scan to email', 'issue_classification', 42, 'title_or_description', 'contains', '["scan to email", "scan-to-email"]', 'issue_type', '51', 'sub_issue_type', '551', 'high'),
('Peripheral - docking station', 'issue_classification', 43, 'title_or_description', 'contains', '["docking station", "dock", "undocking"]', 'issue_type', '51', 'sub_issue_type', '769', 'high'),
('Peripheral - display/monitor', 'issue_classification', 44, 'title_or_description', 'contains', '["monitor", "display", "screen", "external display"]', 'issue_type', '51', 'sub_issue_type', '745', 'medium'),
('Peripheral - audio', 'issue_classification', 45, 'title_or_description', 'contains', '["audio", "speakers", "headset", "microphone", "sound"]', 'issue_type', '51', 'sub_issue_type', '804', 'medium'),
('Peripheral - fax', 'issue_classification', 46, 'title_or_description', 'contains', '["fax", "faxing"]', 'issue_type', '51', 'sub_issue_type', '847', 'high');
-- Software (issueType 46)
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, result_field_2, result_value_2, confidence) VALUES
('Software - MS Teams', 'issue_classification', 50, 'title_or_description', 'contains', '["microsoft teams", "ms teams", "teams meeting", "teams call"]', 'issue_type', '46', 'sub_issue_type', '798', 'high'),
('Software - SharePoint', 'issue_classification', 51, 'title_or_description', 'contains', '["sharepoint"]', 'issue_type', '46', 'sub_issue_type', '850', 'high'),
('Software - OneDrive', 'issue_classification', 52, 'title_or_description', 'contains', '["onedrive", "one drive"]', 'issue_type', '46', 'sub_issue_type', '345', 'high'),
('Software - Excel', 'issue_classification', 53, 'title_or_description', 'contains', '["excel", "spreadsheet"]', 'issue_type', '46', 'sub_issue_type', '842', 'high'),
('Software - Word', 'issue_classification', 54, 'title_or_description', 'contains', '["microsoft word", "ms word"]', 'issue_type', '46', 'sub_issue_type', '799', 'high'),
('Software - PowerPoint', 'issue_classification', 55, 'title_or_description', 'contains', '["powerpoint", "power point"]', 'issue_type', '46', 'sub_issue_type', '800', 'high'),
('Software - MS Project', 'issue_classification', 56, 'title_or_description', 'contains', '["ms project", "microsoft project"]', 'issue_type', '46', 'sub_issue_type', '836', 'high'),
('Software - Adobe', 'issue_classification', 57, 'title_or_description', 'contains', '["adobe", "acrobat", "photoshop", "illustrator"]', 'issue_type', '46', 'sub_issue_type', '541', 'high'),
('Software - Chrome', 'issue_classification', 58, 'title_or_description', 'contains', '["google chrome", "chrome browser"]', 'issue_type', '46', 'sub_issue_type', '542', 'high'),
('Software - QuickBooks', 'issue_classification', 59, 'title_or_description', 'contains', '["quickbooks", "quick books"]', 'issue_type', '46', 'sub_issue_type', '583', 'high'),
('Software - Bitwarden', 'issue_classification', 60, 'title_or_description', 'contains', '["bitwarden"]', 'issue_type', '46', 'sub_issue_type', '851', 'high'),
('Software - MS Office generic', 'issue_classification', 69, 'title_or_description', 'contains', '["microsoft sway", "onenote", "one note", "visio", "publisher", "ms access", "microsoft forms", "planner"]', 'issue_type', '46', 'sub_issue_type', '332', 'high');
-- Networking (issueType 25)
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, result_field_2, result_value_2, confidence) VALUES
('Network - WiFi', 'issue_classification', 70, 'title_or_description', 'contains', '["wifi", "wi-fi", "wireless", "wireless network"]', 'issue_type', '25', 'sub_issue_type', '548', 'high'),
('Network - VPN', 'issue_classification', 71, 'title_or_description', 'contains', '["vpn", "site to site"]', 'issue_type', '25', 'sub_issue_type', '358', 'medium'),
('Network - internet down', 'issue_classification', 72, 'title_or_description', 'contains', '["internet down", "internet outage", "no internet", "internet not working"]', 'issue_type', '25', 'sub_issue_type', '260', 'high'),
('Network - firewall', 'issue_classification', 73, 'title_or_description', 'contains', '["firewall"]', 'issue_type', '25', 'sub_issue_type', '244', 'high');
-- Hardware (issueType 31)
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, result_field_2, result_value_2, confidence) VALUES
('Hardware - slow PC', 'issue_classification', 80, 'title_or_description', 'contains', '["slow computer", "slow pc", "computer slow", "pc slow", "running slow"]', 'issue_type', '31', 'sub_issue_type', '560', 'high'),
('Hardware - laptop', 'issue_classification', 81, 'title_or_description', 'contains', '["laptop issue", "laptop problem", "laptop broken"]', 'issue_type', '31', 'sub_issue_type', '561', 'medium'),
('Hardware - desktop', 'issue_classification', 82, 'title_or_description', 'contains', '["desktop issue", "desktop problem", "desktop broken", "computer won''t turn on"]', 'issue_type', '31', 'sub_issue_type', '248', 'medium');
-- Remote Access (issueType 63)
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, result_field_2, result_value_2, confidence) VALUES
('Remote Access - VPN/Sophos', 'issue_classification', 90, 'title_or_description', 'contains', '["sophos vpn", "sophos connect"]', 'issue_type', '63', 'sub_issue_type', '494', 'high'),
('Remote Access - RDP', 'issue_classification', 91, 'title_or_description', 'contains', '["rdp", "remote desktop", "rds gateway"]', 'issue_type', '63', 'sub_issue_type', '495', 'high'),
('Remote Access - generic', 'issue_classification', 99, 'title_or_description', 'contains', '["remote access", "work from home", "remote work"]', 'issue_type', '63', 'sub_issue_type', '770', 'medium');
-- Collaboration (issueType 42) - for phone/video specific
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, result_field_2, result_value_2, confidence) VALUES
('Collaboration - Nextiva', 'issue_classification', 100, 'title_or_description', 'contains', '["nextiva", "phone system"]', 'issue_type', '42', 'sub_issue_type', '563', 'high'),
('Collaboration - Teams Room', 'issue_classification', 101, 'title_or_description', 'contains', '["teams room", "conference room teams"]', 'issue_type', '42', 'sub_issue_type', '810', 'high');
-- Security (issueType 59)
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, result_field_2, result_value_2, confidence) VALUES
('Security - phishing', 'issue_classification', 110, 'title_or_description', 'contains', '["phishing", "phish"]', 'issue_type', '59', 'sub_issue_type', '910', 'high'),
('Security - malware', 'issue_classification', 111, 'title_or_description', 'contains', '["malware", "virus", "trojan"]', 'issue_type', '59', 'sub_issue_type', '554', 'high'),
('Security - ransomware', 'issue_classification', 112, 'title_or_description', 'contains', '["ransomware"]', 'issue_type', '59', 'sub_issue_type', '911', 'high'),
('Security - compromised account', 'issue_classification', 113, 'title_or_description', 'contains', '["compromised account", "account compromised", "account hacked"]', 'issue_type', '59', 'sub_issue_type', '552', 'high'),
('Security - 2FA/MFA setup', 'issue_classification', 114, 'title_or_description', 'contains', '["2fa setup", "mfa setup", "two factor", "multi factor"]', 'issue_type', '59', 'sub_issue_type', '860', 'high'),
('Security - DUO enrollment', 'issue_classification', 115, 'title_or_description', 'contains', '["duo enrollment", "duo setup", "enroll duo"]', 'issue_type', '59', 'sub_issue_type', '813', 'high');
-- ============================================================================
-- SEED: Priority classification rules
-- ============================================================================
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, confidence) VALUES
-- Category-based overrides (highest priority)
('Priority - User Separation = Critical', 'priority', 1, 'ticket_category', 'equals', '128', 'priority', '4', 'high'),
('Priority - New User = Minor Service', 'priority', 2, 'ticket_category', 'equals', '127', 'priority', '8', 'high'),
-- Security events
('Priority - Security keywords = Security Event', 'priority', 10, 'title_or_description', 'contains', '["security breach", "ransomware", "malware detected", "compromised", "attack"]', 'priority', '7', 'high'),
-- Impact-based
('Priority - Multiple users affected = Critical', 'priority', 20, 'title_or_description', 'contains', '["multiple users", "everyone", "all users", "company-wide", "entire office"]', 'priority', '4', 'high'),
-- Simple service requests
('Priority - How-to/consultation = Minor Service', 'priority', 30, 'title_or_description', 'contains', '["how do i", "how to", "schedule meeting", "consultation", "question about", "following up"]', 'priority', '8', 'medium');
-- ============================================================================
-- SEED: Queue routing classification rules
-- ============================================================================
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, confidence) VALUES
-- Critical priority override
('Queue - Critical = Level 2', 'queue_routing', 1, 'priority', 'equals', '4', 'queue_id', '29682969', 'high'),
-- Device name patterns (workstation)
('Queue - DT devices = Level 1', 'queue_routing', 10, 'title_or_description', 'regex', '"\\bDT-?\\d{2,}"', 'queue_id', '29682833', 'high'),
('Queue - LT devices = Level 1', 'queue_routing', 11, 'title_or_description', 'regex', '"\\bLT-?\\d{2,}"', 'queue_id', '29682833', 'high'),
('Queue - SP devices = Level 1', 'queue_routing', 12, 'title_or_description', 'regex', '"\\bSP-?\\d{2,}"', 'queue_id', '29682833', 'high'),
-- Policy name patterns
('Queue - Workstation policy = Level 1', 'queue_routing', 20, 'title_or_description', 'contains', '["windows workstation", "workstations"]', 'queue_id', '29682833', 'high'),
('Queue - Server policy = Level 2', 'queue_routing', 21, 'title_or_description', 'contains', '["windows server", "hypervisor", "esxi", "hyper-v"]', 'queue_id', '29682969', 'high');

BIN
public/autotask-logo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/logos/addigy.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

BIN
public/logos/autotask.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/logos/auvik.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

BIN
public/logos/datto-rmm.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/logos/veeam.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/veeam-logo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB