ritesh19180 commited on
Commit
3b2aa49
·
verified ·
1 Parent(s): 7e33bee

Upload folder using huggingface_hub

Browse files
Frontend/src/App.jsx CHANGED
@@ -54,6 +54,7 @@ import AdminUsers from "./admin/pages/AdminUsers";
54
  import AdminAnalytics from "./admin/pages/AdminAnalytics";
55
  import AdminProfile from "./admin/pages/AdminProfile";
56
  import AdminSettings from "./admin/pages/AdminSettings";
 
57
  import MasterBugReports from "./master-admin/pages/MasterBugReports";
58
 
59
  // Feature Pages
@@ -78,7 +79,6 @@ import MasterAdminDashboard from "./master-admin/pages/MasterAdminDashboard";
78
  import PendingAdminRequests from "./master-admin/pages/PendingAdminRequests";
79
  import AllCompanies from "./master-admin/pages/AllCompanies";
80
  import AllAdmins from "./master-admin/pages/AllAdmins";
81
- import Changelog from "./pages/Changelog";
82
 
83
 
84
  function TitleUpdater() {
@@ -96,6 +96,7 @@ function TitleUpdater() {
96
  else if (path.startsWith('/admin/analytics')) title = 'Analytics | Admin';
97
  else if (path.startsWith('/admin/profile')) title = 'Admin Profile';
98
  else if (path.startsWith('/admin/settings')) title = 'Settings | Admin';
 
99
  // Master Admin Routes
100
  else if (path.startsWith('/master-admin/dashboard')) title = 'Master Dashboard';
101
  else if (path.startsWith('/master-admin/admin-requests')) title = 'Pending Requests | Master Admin';
@@ -191,6 +192,7 @@ function AppLayout() {
191
  <Route path="/admin/analytics" element={<AdminAnalytics />} />
192
  <Route path="/admin/profile" element={<AdminProfile />} />
193
  <Route path="/admin/settings" element={<AdminSettings />} />
 
194
  </Route>
195
  </Route>
196
 
@@ -206,11 +208,6 @@ function App() {
206
 
207
  useEffect(() => {
208
  initialize();
209
- // Dark mode initialize
210
- const saved = localStorage.getItem('theme');
211
- if (saved === 'dark') {
212
- document.documentElement.classList.add('dark');
213
- }
214
  }, [initialize]);
215
 
216
  return (
@@ -237,9 +234,6 @@ function App() {
237
  <Route path="/features/priority" element={<PriorityDetectionFeature />} />
238
  <Route path="/features/resolution" element={<SmartResolutionFeature />} />
239
 
240
- {/* Resources Pages */}
241
- <Route path="/changelog" element={<Changelog />} />
242
-
243
  {/* Legal Pages */}
244
  <Route path="/terms" element={<TermsOfService />} />
245
  <Route path="/privacy" element={<PrivacyPolicy />} />
 
54
  import AdminAnalytics from "./admin/pages/AdminAnalytics";
55
  import AdminProfile from "./admin/pages/AdminProfile";
56
  import AdminSettings from "./admin/pages/AdminSettings";
57
+ import SLAPage from "./admin/pages/SLAPage";
58
  import MasterBugReports from "./master-admin/pages/MasterBugReports";
59
 
60
  // Feature Pages
 
79
  import PendingAdminRequests from "./master-admin/pages/PendingAdminRequests";
80
  import AllCompanies from "./master-admin/pages/AllCompanies";
81
  import AllAdmins from "./master-admin/pages/AllAdmins";
 
82
 
83
 
84
  function TitleUpdater() {
 
96
  else if (path.startsWith('/admin/analytics')) title = 'Analytics | Admin';
97
  else if (path.startsWith('/admin/profile')) title = 'Admin Profile';
98
  else if (path.startsWith('/admin/settings')) title = 'Settings | Admin';
99
+ else if (path.startsWith('/admin/sla')) title = 'SLA Monitor | Admin';
100
  // Master Admin Routes
101
  else if (path.startsWith('/master-admin/dashboard')) title = 'Master Dashboard';
102
  else if (path.startsWith('/master-admin/admin-requests')) title = 'Pending Requests | Master Admin';
 
192
  <Route path="/admin/analytics" element={<AdminAnalytics />} />
193
  <Route path="/admin/profile" element={<AdminProfile />} />
194
  <Route path="/admin/settings" element={<AdminSettings />} />
195
+ <Route path="/admin/sla" element={<SLAPage />} />
196
  </Route>
197
  </Route>
198
 
 
208
 
209
  useEffect(() => {
210
  initialize();
 
 
 
 
 
211
  }, [initialize]);
212
 
213
  return (
 
234
  <Route path="/features/priority" element={<PriorityDetectionFeature />} />
235
  <Route path="/features/resolution" element={<SmartResolutionFeature />} />
236
 
 
 
 
237
  {/* Legal Pages */}
238
  <Route path="/terms" element={<TermsOfService />} />
239
  <Route path="/privacy" element={<PrivacyPolicy />} />
Frontend/src/admin/components/AdminSidebar.jsx CHANGED
@@ -10,7 +10,8 @@ import {
10
  LogOut,
11
  Activity,
12
  ChevronLeft,
13
- ChevronRight
 
14
  } from 'lucide-react';
15
  import useAuthStore from '../../store/authStore';
16
 
@@ -21,6 +22,7 @@ const AdminSidebar = ({ isMobile, onClose, isCollapsed, onToggleCollapse }) => {
21
  { label: 'Users', path: '/admin/users', icon: Users },
22
  { label: 'Analytics', path: '/admin/analytics', icon: BarChart3 },
23
  { label: 'Profile', path: '/admin/profile', icon: UserCircle },
 
24
  ];
25
 
26
  const { logout } = useAuthStore();
 
10
  LogOut,
11
  Activity,
12
  ChevronLeft,
13
+ ChevronRight,
14
+ Clock
15
  } from 'lucide-react';
16
  import useAuthStore from '../../store/authStore';
17
 
 
22
  { label: 'Users', path: '/admin/users', icon: Users },
23
  { label: 'Analytics', path: '/admin/analytics', icon: BarChart3 },
24
  { label: 'Profile', path: '/admin/profile', icon: UserCircle },
25
+ { label: 'SLA Monitor', path: '/admin/sla', icon: Clock },
26
  ];
27
 
28
  const { logout } = useAuthStore();
Frontend/src/admin/components/SLABadge.jsx CHANGED
@@ -1,12 +1,12 @@
1
  import React, { useState, useEffect } from 'react';
2
  import { Clock, AlertTriangle, ShieldCheck } from 'lucide-react';
3
 
4
- // SLA resolution limits in milliseconds based on priority.
5
  const SLA_LIMITS = {
6
- critical: 4 * 60 * 60 * 1000,
7
- high: 12 * 60 * 60 * 1000,
8
- medium: 24 * 60 * 60 * 1000,
9
- low: 72 * 60 * 60 * 1000,
10
  };
11
 
12
  function formatDuration(ms) {
@@ -19,47 +19,36 @@ function formatDuration(ms) {
19
  }
20
 
21
  /**
22
- * SLABadge — shows SLA status for a ticket.
23
  *
24
  * Props:
25
  * - priority: string ('critical' | 'high' | 'medium' | 'low')
26
  * - createdAt: string (ISO date string)
27
- * - slaBreachAt: string (ISO date string) — preferred persisted deadline
28
- * - slaStatus: string ('ACTIVE' | 'WARNING' | 'BREACHED')
29
  * - status: string — if ticket is resolved/closed, show "Met" without countdown
30
  * - compact: bool — if true, shows just the badge with no label text
31
  */
32
- export default function SLABadge({ priority, createdAt, slaBreachAt, slaStatus, status, compact = false }) {
33
  const [remaining, setRemaining] = useState(null);
34
 
35
- const normalizedStatus = status?.toLowerCase();
36
- const normalizedSlaStatus = slaStatus?.toUpperCase();
37
- const isResolved = ['resolved', 'closed', 'auto-resolved', 'auto resolved'].includes(normalizedStatus);
38
 
39
  useEffect(() => {
40
- if (isResolved) return;
41
- if (normalizedSlaStatus === 'BREACHED') {
42
- setRemaining(-1);
43
- return;
44
- }
45
 
46
- const priorityKey = priority?.toLowerCase?.() || 'medium';
47
  const limit = SLA_LIMITS[priorityKey] || SLA_LIMITS.medium;
48
- const deadlineMs = slaBreachAt
49
- ? new Date(slaBreachAt).getTime()
50
- : new Date(createdAt).getTime() + limit;
51
-
52
- if (!Number.isFinite(deadlineMs)) return;
53
 
54
  const calculate = () => {
55
- const rem = deadlineMs - Date.now();
 
56
  setRemaining(rem);
57
  };
58
 
59
  calculate();
60
  const timer = setInterval(calculate, 60 * 1000); // update every minute
61
  return () => clearInterval(timer);
62
- }, [priority, createdAt, slaBreachAt, normalizedSlaStatus, isResolved]);
63
 
64
  if (isResolved) {
65
  return (
 
1
  import React, { useState, useEffect } from 'react';
2
  import { Clock, AlertTriangle, ShieldCheck } from 'lucide-react';
3
 
4
+ // SLA time limits in milliseconds based on priority
5
  const SLA_LIMITS = {
6
+ critical: 2 * 60 * 60 * 1000, // 2 hours
7
+ high: 4 * 60 * 60 * 1000, // 4 hours
8
+ medium: 8 * 60 * 60 * 1000, // 8 hours
9
+ low: 24 * 60 * 60 * 1000, // 24 hours
10
  };
11
 
12
  function formatDuration(ms) {
 
19
  }
20
 
21
  /**
22
+ * SLABadge — shows SLA status for a ticket based on its priority and creation time.
23
  *
24
  * Props:
25
  * - priority: string ('critical' | 'high' | 'medium' | 'low')
26
  * - createdAt: string (ISO date string)
 
 
27
  * - status: string — if ticket is resolved/closed, show "Met" without countdown
28
  * - compact: bool — if true, shows just the badge with no label text
29
  */
30
+ export default function SLABadge({ priority, createdAt, status, compact = false }) {
31
  const [remaining, setRemaining] = useState(null);
32
 
33
+ const isResolved = ['resolved', 'closed', 'auto-resolved'].includes(status?.toLowerCase());
 
 
34
 
35
  useEffect(() => {
36
+ if (isResolved || !priority || !createdAt) return;
 
 
 
 
37
 
38
+ const priorityKey = priority.toLowerCase();
39
  const limit = SLA_LIMITS[priorityKey] || SLA_LIMITS.medium;
40
+ const createdMs = new Date(createdAt).getTime();
 
 
 
 
41
 
42
  const calculate = () => {
43
+ const elapsed = Date.now() - createdMs;
44
+ const rem = limit - elapsed;
45
  setRemaining(rem);
46
  };
47
 
48
  calculate();
49
  const timer = setInterval(calculate, 60 * 1000); // update every minute
50
  return () => clearInterval(timer);
51
+ }, [priority, createdAt, isResolved]);
52
 
53
  if (isResolved) {
54
  return (
Frontend/src/admin/components/SLADashboard.jsx ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * SLA Dashboard Widget — Real-time SLA monitoring panel
3
+ *
4
+ * Displays:
5
+ * - Active vs breached vs warning counts
6
+ * - Breach rate percentage
7
+ * - Per-priority breakdown
8
+ * - Recent escalations
9
+ * - Trend indicators
10
+ */
11
+
12
+ import React, { useState, useEffect, useMemo } from 'react';
13
+ import {
14
+ Clock,
15
+ AlertTriangle,
16
+ ShieldCheck,
17
+ TrendingUp,
18
+ TrendingDown,
19
+ AlertCircle,
20
+ Activity,
21
+ RefreshCw,
22
+ ArrowUpRight,
23
+ ChevronRight,
24
+ Users,
25
+ Gauge,
26
+ } from 'lucide-react';
27
+
28
+ const PRIORITY_COLORS = {
29
+ critical: { bg: '#FEF2F2', text: '#DC2626', border: '#FECACA', dot: '#DC2626' },
30
+ high: { bg: '#FFF7ED', text: '#EA580C', border: '#FED7AA', dot: '#EA580C' },
31
+ medium: { bg: '#FEFCE8', text: '#CA8A04', border: '#FDE68A', dot: '#CA8A04' },
32
+ low: { bg: '#F0FDF4', text: '#16A34A', border: '#BBF7D0', dot: '#16A34A' },
33
+ };
34
+
35
+ const API_BASE = import.meta.env.VITE_API_URL || 'https://helpdesk-ai-backend-iq0w.onrender.com';
36
+
37
+ /**
38
+ * Fetch SLA dashboard stats from the backend.
39
+ */
40
+ async function fetchSLAStats() {
41
+ const res = await fetch(`${API_BASE}/sla/stats`, {
42
+ headers: { 'Content-Type': 'application/json' },
43
+ });
44
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
45
+ return res.json();
46
+ }
47
+
48
+ /**
49
+ * Fetch recent escalations.
50
+ */
51
+ async function fetchEscalations(limit = 10) {
52
+ const res = await fetch(`${API_BASE}/sla/escalations?limit=${limit}`, {
53
+ headers: { 'Content-Type': 'application/json' },
54
+ });
55
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
56
+ return res.json();
57
+ }
58
+
59
+ /**
60
+ * Format seconds to human-readable duration.
61
+ */
62
+ function formatDuration(seconds) {
63
+ if (!seconds && seconds !== 0) return '—';
64
+ if (seconds <= 0) return 'Overdue';
65
+ const hrs = Math.floor(seconds / 3600);
66
+ const mins = Math.floor((seconds % 3600) / 60);
67
+ if (hrs > 0) return `${hrs}h ${mins}m`;
68
+ return `${mins}m`;
69
+ }
70
+
71
+ /**
72
+ * Main SLA Dashboard Component
73
+ */
74
+ export default function SLADashboard({ compact = false, onViewAll }) {
75
+ const [stats, setStats] = useState(null);
76
+ const [escalations, setEscalations] = useState([]);
77
+ const [loading, setLoading] = useState(true);
78
+ const [error, setError] = useState(null);
79
+ const [refreshing, setRefreshing] = useState(false);
80
+
81
+ const fetchData = async () => {
82
+ try {
83
+ setRefreshing(true);
84
+ const [statsData, escData] = await Promise.all([
85
+ fetchSLAStats(),
86
+ fetchEscalations(compact ? 5 : 10),
87
+ ]);
88
+ setStats(statsData);
89
+ setEscalations(escData?.escalations || escData || []);
90
+ setError(null);
91
+ } catch (err) {
92
+ console.error('[SLA-Dashboard] Fetch error:', err);
93
+ setError(err.message);
94
+ } finally {
95
+ setLoading(false);
96
+ setRefreshing(false);
97
+ }
98
+ };
99
+
100
+ useEffect(() => { fetchData(); }, []);
101
+
102
+ // Auto-refresh every 60 seconds
103
+ useEffect(() => {
104
+ const interval = setInterval(fetchData, 60_000);
105
+ return () => clearInterval(interval);
106
+ }, []);
107
+
108
+ // ── KPIs ──────────────────────────────────────────────────────────────────
109
+
110
+ const kpis = useMemo(() => {
111
+ if (!stats) return [];
112
+ return [
113
+ {
114
+ label: 'Active Tickets',
115
+ value: stats.active ?? 0,
116
+ icon: Activity,
117
+ color: '#3b82f6',
118
+ bg: '#EFF6FF',
119
+ border: '#BFDBFE',
120
+ subtitle: 'Being monitored',
121
+ },
122
+ {
123
+ label: 'SLA Warnings',
124
+ value: stats.warning ?? 0,
125
+ icon: AlertTriangle,
126
+ color: '#F59E0B',
127
+ bg: '#FEFCE8',
128
+ border: '#FDE68A',
129
+ subtitle: 'Nearing breach',
130
+ pulse: (stats.warning ?? 0) > 0,
131
+ },
132
+ {
133
+ label: 'SLA Breached',
134
+ value: stats.breached ?? 0,
135
+ icon: AlertCircle,
136
+ color: '#DC2626',
137
+ bg: '#FEF2F2',
138
+ border: '#FECACA',
139
+ subtitle: 'Requires immediate action',
140
+ pulse: (stats.breached ?? 0) > 0,
141
+ },
142
+ {
143
+ label: 'Breach Rate',
144
+ value: stats.breach_rate != null ? `${stats.breach_rate}%` : '0%',
145
+ icon: Gauge,
146
+ color: (stats.breach_rate ?? 0) > 10 ? '#DC2626' : '#16A34A',
147
+ bg: (stats.breach_rate ?? 0) > 10 ? '#FEF2F2' : '#F0FDF4',
148
+ border: (stats.breach_rate ?? 0) > 10 ? '#FECACA' : '#BBF7D0',
149
+ subtitle: 'Of total tickets',
150
+ },
151
+ ];
152
+ }, [stats]);
153
+
154
+ // ── Loading state ─────────────────────────────────────────────────────────
155
+
156
+ if (loading) {
157
+ return (
158
+ <div className="py-16 text-center">
159
+ <RefreshCw size={32} className="animate-spin mx-auto mb-4 text-emerald-500" />
160
+ <p className="text-sm text-gray-400 font-semibold uppercase tracking-wider">
161
+ Loading SLA Dashboard...
162
+ </p>
163
+ </div>
164
+ );
165
+ }
166
+
167
+ if (error) {
168
+ return (
169
+ <div className="py-16 text-center" style={{ border: '2px dashed #FECACA', borderRadius: '16px' }}>
170
+ <AlertCircle size={40} className="mx-auto mb-3 text-red-400" />
171
+ <h3 className="text-sm font-bold text-red-600 mb-1">Connection Error</h3>
172
+ <p className="text-xs text-gray-500">{error}</p>
173
+ <button
174
+ onClick={fetchData}
175
+ className="mt-4 px-4 py-2 bg-red-50 text-red-600 border border-red-200 rounded-lg text-xs font-bold uppercase tracking-wider hover:bg-red-100 transition-colors"
176
+ >
177
+ Retry
178
+ </button>
179
+ </div>
180
+ );
181
+ }
182
+
183
+ // ── Empty state ───────────────────────────────────────────────────────────
184
+
185
+ if (!stats || (stats.active === 0 && stats.breached === 0)) {
186
+ return (
187
+ <div className="py-16 text-center" style={{ border: '2px dashed #D1FAE5', borderRadius: '16px' }}>
188
+ <ShieldCheck size={40} className="mx-auto mb-3 text-emerald-400" />
189
+ <h3 className="text-sm font-bold text-emerald-700 mb-1">All SLA Targets Met</h3>
190
+ <p className="text-xs text-gray-500">No active SLA violations.</p>
191
+ </div>
192
+ );
193
+ }
194
+
195
+ // ── Render ────────────────────────────────────────────────────────────────
196
+
197
+ return (
198
+ <div className="space-y-6">
199
+ {/* Header with refresh */}
200
+ <div className="flex items-center justify-between">
201
+ <div className="flex items-center gap-2">
202
+ <Clock size={18} className="text-emerald-500" />
203
+ <h3 className="text-sm font-bold text-gray-800">SLA Monitoring</h3>
204
+ </div>
205
+ <button
206
+ onClick={fetchData}
207
+ disabled={refreshing}
208
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-gray-500 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 transition-colors disabled:opacity-50"
209
+ >
210
+ <RefreshCw size={12} className={refreshing ? 'animate-spin' : ''} />
211
+ {refreshing ? 'Refreshing...' : 'Refresh'}
212
+ </button>
213
+ </div>
214
+
215
+ {/* KPI Cards */}
216
+ <div className={`grid gap-4 ${compact ? 'grid-cols-2' : 'grid-cols-2 lg:grid-cols-4'}`}>
217
+ {kpis.map((kpi, idx) => (
218
+ <div
219
+ key={idx}
220
+ className="rounded-xl p-4 border transition-all hover:shadow-md"
221
+ style={{
222
+ background: kpi.bg,
223
+ borderColor: kpi.border,
224
+ }}
225
+ >
226
+ <div className="flex items-center justify-between mb-3">
227
+ <kpi.icon size={20} style={{ color: kpi.color }} />
228
+ <div
229
+ className={`w-2 h-2 rounded-full ${kpi.pulse ? 'animate-pulse' : ''}`}
230
+ style={{ background: kpi.color }}
231
+ />
232
+ </div>
233
+ <p className="text-2xl font-bold" style={{ color: kpi.color }}>
234
+ {kpi.value}
235
+ </p>
236
+ <p className="text-xs font-semibold text-gray-600 mt-1">{kpi.label}</p>
237
+ <p className="text-[10px] text-gray-400 mt-0.5">{kpi.subtitle}</p>
238
+ </div>
239
+ ))}
240
+ </div>
241
+
242
+ {/* Per-Priority Breakdown */}
243
+ {stats?.by_priority && !compact && (
244
+ <div className="rounded-2xl border border-gray-100 bg-white p-5">
245
+ <h4 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-4">
246
+ SLA Breakdown by Priority
247
+ </h4>
248
+ <div className="space-y-3">
249
+ {Object.entries(stats.by_priority).map(([priority, data]) => {
250
+ const colors = PRIORITY_COLORS[priority] || PRIORITY_COLORS.medium;
251
+ const total = data.total || 0;
252
+ const breached = data.breached || 0;
253
+ const warning = data.warning || 0;
254
+ const healthy = total - breached - warning;
255
+ const breachPct = total > 0 ? Math.round((breached / total) * 100) : 0;
256
+
257
+ return (
258
+ <div key={priority} className="flex items-center gap-3">
259
+ <span
260
+ className="text-[10px] font-bold uppercase tracking-wider min-w-[60px]"
261
+ style={{ color: colors.text }}
262
+ >
263
+ {priority}
264
+ </span>
265
+ <div className="flex-1 h-2.5 rounded-full bg-gray-100 overflow-hidden flex">
266
+ {healthy > 0 && (
267
+ <div
268
+ className="h-full transition-all duration-500"
269
+ style={{
270
+ width: `${total > 0 ? (healthy / total) * 100 : 0}%`,
271
+ background: '#16A34A',
272
+ }}
273
+ />
274
+ )}
275
+ {warning > 0 && (
276
+ <div
277
+ className="h-full transition-all duration-500"
278
+ style={{
279
+ width: `${total > 0 ? (warning / total) * 100 : 0}%`,
280
+ background: '#F59E0B',
281
+ }}
282
+ />
283
+ )}
284
+ {breached > 0 && (
285
+ <div
286
+ className="h-full transition-all duration-500"
287
+ style={{
288
+ width: `${total > 0 ? (breached / total) * 100 : 0}%`,
289
+ background: '#DC2626',
290
+ }}
291
+ />
292
+ )}
293
+ </div>
294
+ <span className="text-xs font-semibold text-gray-600 min-w-[40px] text-right">
295
+ {total}
296
+ </span>
297
+ {breachPct > 0 && (
298
+ <span className="text-[10px] font-bold text-red-500 min-w-[36px] text-right">
299
+ {breachPct}%
300
+ </span>
301
+ )}
302
+ </div>
303
+ );
304
+ })}
305
+ </div>
306
+ </div>
307
+ )}
308
+
309
+ {/* Recent Escalations */}
310
+ {escalations.length > 0 && !compact && (
311
+ <div className="rounded-2xl border border-gray-100 bg-white p-5">
312
+ <div className="flex items-center justify-between mb-4">
313
+ <h4 className="text-xs font-bold text-gray-500 uppercase tracking-wider">
314
+ Recent Escalations
315
+ </h4>
316
+ {onViewAll && (
317
+ <button
318
+ onClick={onViewAll}
319
+ className="text-xs font-semibold text-emerald-600 hover:text-emerald-700 flex items-center gap-1"
320
+ >
321
+ View All <ChevronRight size={12} />
322
+ </button>
323
+ )}
324
+ </div>
325
+ <div className="space-y-2">
326
+ {escalations.slice(0, compact ? 3 : 5).map((esc, idx) => {
327
+ const status = esc.sla_status || 'breached';
328
+ const level = esc.escalation_level || 0;
329
+ const isBreached = status === 'breached';
330
+ return (
331
+ <div
332
+ key={esc.id || idx}
333
+ className="flex items-center justify-between p-3 rounded-xl border transition-colors hover:bg-gray-50"
334
+ style={{
335
+ borderColor: isBreached ? '#FECACA' : '#FDE68A',
336
+ background: isBreached ? '#FFFBFB' : '#FFFEF9',
337
+ }}
338
+ >
339
+ <div className="flex items-center gap-3 min-w-0">
340
+ <div
341
+ className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
342
+ style={{ background: isBreached ? '#FEF2F2' : '#FEFCE8' }}
343
+ >
344
+ {isBreached
345
+ ? <AlertCircle size={14} className="text-red-500" />
346
+ : <AlertTriangle size={14} className="text-amber-500" />
347
+ }
348
+ </div>
349
+ <div className="min-w-0">
350
+ <p className="text-xs font-semibold text-gray-800 truncate">
351
+ {esc.ticket_subject || `Ticket #${(esc.ticket_id || '').toString().slice(0, 8)}`}
352
+ </p>
353
+ <p className="text-[10px] text-gray-400">
354
+ L{level} · {esc.assigned_team || 'Unassigned'}
355
+ {esc.remaining_seconds != null && ` · ${formatDuration(esc.remaining_seconds)}`}
356
+ </p>
357
+ </div>
358
+ </div>
359
+ <span
360
+ className={`text-[10px] font-bold px-2 py-1 rounded-full uppercase tracking-wider flex-shrink-0 ${
361
+ isBreached
362
+ ? 'bg-red-50 text-red-600 border border-red-200'
363
+ : 'bg-amber-50 text-amber-600 border border-amber-200'
364
+ }`}
365
+ >
366
+ {status}
367
+ </span>
368
+ </div>
369
+ );
370
+ })}
371
+ </div>
372
+ </div>
373
+ )}
374
+ </div>
375
+ );
376
+ }
Frontend/src/admin/pages/SLAPage.jsx ADDED
@@ -0,0 +1,538 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * SLAPage — Dedicated SLA Monitoring & Management Page
3
+ *
4
+ * Features:
5
+ * - Real-time SLA dashboard with KPIs
6
+ * - Per-priority breakdown visualization
7
+ * - Active violations list with escalation tracking
8
+ * - Escalation timeline / audit log
9
+ * - Alert configuration panel
10
+ */
11
+
12
+ import React, { useState, useEffect, useMemo, useCallback } from 'react';
13
+ import {
14
+ Clock,
15
+ AlertTriangle,
16
+ AlertCircle,
17
+ ShieldCheck,
18
+ RefreshCw,
19
+ ChevronDown,
20
+ ChevronUp,
21
+ Filter,
22
+ Bell,
23
+ Settings,
24
+ ExternalLink,
25
+ Gauge,
26
+ Activity,
27
+ Users,
28
+ TrendingUp,
29
+ } from 'lucide-react';
30
+ import SLADashboard from '../components/SLADashboard';
31
+ import SLABadge from '../components/SLABadge';
32
+
33
+ const API_BASE = import.meta.env.VITE_API_URL || 'https://helpdesk-ai-backend-iq0w.onrender.com';
34
+
35
+ // ── Data fetching helpers ────────────────────────────────────────────────────
36
+
37
+ async function fetchSLATickets(status = 'all') {
38
+ const params = status !== 'all' ? `?status=${status}` : '';
39
+ const res = await fetch(`${API_BASE}/sla/tickets${params}`, {
40
+ headers: { 'Content-Type': 'application/json' },
41
+ });
42
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
43
+ return res.json();
44
+ }
45
+
46
+ async function fetchEscalations(limit = 50) {
47
+ const res = await fetch(`${API_BASE}/sla/escalations?limit=${limit}`, {
48
+ headers: { 'Content-Type': 'application/json' },
49
+ });
50
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
51
+ return res.json();
52
+ }
53
+
54
+ async function fetchSLAPolicies() {
55
+ const res = await fetch(`${API_BASE}/sla/policies`, {
56
+ headers: { 'Content-Type': 'application/json' },
57
+ });
58
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
59
+ return res.json();
60
+ }
61
+
62
+ // ── Status styles ────────────────────────────────────────────────────────────
63
+
64
+ const STATUS_STYLES = {
65
+ breached: { bg: '#FEF2F2', text: '#DC2626', border: '#FECACA', icon: AlertCircle, label: 'Breached' },
66
+ warning: { bg: '#FEFCE8', text: '#CA8A04', border: '#FDE68A', icon: AlertTriangle, label: 'Warning' },
67
+ active: { bg: '#F0FDF4', text: '#16A34A', border: '#BBF7D0', icon: Activity, label: 'Active' },
68
+ met: { bg: '#F1F5F9', text: '#64748B', border: '#E2E8F0', icon: ShieldCheck, label: 'SLA Met' },
69
+ };
70
+
71
+ const PRIORITY_STYLES = {
72
+ critical: { bg: '#FEF2F2', text: '#DC2626', border: '#FECACA' },
73
+ high: { bg: '#FFF7ED', text: '#EA580C', border: '#FED7AA' },
74
+ medium: { bg: '#FEFCE8', text: '#CA8A04', border: '#FDE68A' },
75
+ low: { bg: '#F0FDF4', text: '#16A34A', border: '#BBF7D0' },
76
+ };
77
+
78
+ function formatDuration(seconds) {
79
+ if (seconds == null) return '—';
80
+ if (seconds <= 0) return 'Overdue';
81
+ const hrs = Math.floor(seconds / 3600);
82
+ const mins = Math.floor((seconds % 3600) / 60);
83
+ if (hrs > 0) return `${hrs}h ${mins}m`;
84
+ return `${mins}m`;
85
+ }
86
+
87
+ function formatDate(iso) {
88
+ if (!iso) return '—';
89
+ const d = new Date(iso);
90
+ const now = new Date();
91
+ const diffMs = now - d;
92
+ const diffMin = Math.floor(diffMs / 60000);
93
+ if (diffMin < 1) return 'Just now';
94
+ if (diffMin < 60) return `${diffMin}m ago`;
95
+ const diffHr = Math.floor(diffMin / 60);
96
+ if (diffHr < 24) return `${diffHr}h ago`;
97
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
98
+ }
99
+
100
+ // ── SLAPage Component ────────────────────────────────────────────────────────
101
+
102
+ export default function SLAPage() {
103
+ const [activeTab, setActiveTab] = useState('dashboard');
104
+ const [tickets, setTickets] = useState([]);
105
+ const [escalations, setEscalations] = useState([]);
106
+ const [policies, setPolicies] = useState([]);
107
+ const [loading, setLoading] = useState(true);
108
+ const [error, setError] = useState(null);
109
+ const [filterStatus, setFilterStatus] = useState('all');
110
+ const [filterPriority, setFilterPriority] = useState('all');
111
+ const [expandedEsc, setExpandedEsc] = useState(null);
112
+
113
+ // ── Data Loading ────────────────────────────────────────────────────────────
114
+
115
+ const loadData = useCallback(async () => {
116
+ setLoading(true);
117
+ setError(null);
118
+ try {
119
+ const [ticketData, escData, policyData] = await Promise.all([
120
+ fetchSLATickets(filterStatus),
121
+ fetchEscalations(),
122
+ fetchSLAPolicies(),
123
+ ]);
124
+ setTickets(Array.isArray(ticketData) ? ticketData : ticketData?.tickets || []);
125
+ setEscalations(Array.isArray(escData) ? escData : escData?.escalations || []);
126
+ setPolicies(Array.isArray(policyData) ? policyData : policyData?.policies || []);
127
+ } catch (err) {
128
+ console.error('[SLAPage] Fetch error:', err);
129
+ setError(err.message);
130
+ } finally {
131
+ setLoading(false);
132
+ }
133
+ }, [filterStatus]);
134
+
135
+ useEffect(() => { loadData(); }, [loadData]);
136
+
137
+ // ── Filtered tickets ────────────────────────────────────────────────────────
138
+
139
+ const filteredTickets = useMemo(() => {
140
+ let result = tickets;
141
+ if (filterPriority !== 'all') {
142
+ result = result.filter(t => (t.priority || '').toLowerCase() === filterPriority);
143
+ }
144
+ // Sort by severity: breached first, then warning, then active
145
+ const order = { breached: 0, warning: 1, active: 2, met: 3 };
146
+ result = [...result].sort((a, b) => {
147
+ const aOrder = order[a.sla_status] ?? 99;
148
+ const bOrder = order[b.sla_status] ?? 99;
149
+ return aOrder - bOrder;
150
+ });
151
+ return result;
152
+ }, [tickets, filterPriority]);
153
+
154
+ // ── Stats card component ────────────────────────────────────────────────────
155
+
156
+ const StatCard = ({ label, value, icon: Icon, color, bg, border, subtitle, pulse }) => (
157
+ <div
158
+ className="rounded-xl p-5 border transition-all hover:shadow-md"
159
+ style={{ background: bg, borderColor: border }}
160
+ >
161
+ <div className="flex items-center justify-between mb-3">
162
+ <Icon size={22} style={{ color }} />
163
+ {pulse && <span className="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse" />}
164
+ </div>
165
+ <p className="text-3xl font-bold" style={{ color }}>{value}</p>
166
+ <p className="text-sm font-semibold text-gray-600 mt-1">{label}</p>
167
+ {subtitle && <p className="text-xs text-gray-400 mt-0.5">{subtitle}</p>}
168
+ </div>
169
+ );
170
+
171
+ // ── Tab navigation ──────────────────────────────────────────────────────────
172
+
173
+ const tabs = [
174
+ { id: 'dashboard', label: 'Dashboard', icon: Gauge },
175
+ { id: 'violations', label: 'Violations', icon: AlertTriangle },
176
+ { id: 'escalations', label: 'Escalations', icon: Bell },
177
+ { id: 'policies', label: 'SLA Policies', icon: Settings },
178
+ ];
179
+
180
+ // ── Render filters bar ──────────────────────────────────────────────────────
181
+
182
+ const FiltersBar = () => (
183
+ <div className="flex flex-wrap items-center gap-3">
184
+ <div className="flex items-center gap-2 px-3 py-1.5 bg-white border border-gray-200 rounded-lg">
185
+ <Filter size={14} className="text-gray-400" />
186
+ <select
187
+ value={filterStatus}
188
+ onChange={e => setFilterStatus(e.target.value)}
189
+ className="text-xs font-semibold text-gray-600 bg-transparent border-none outline-none cursor-pointer"
190
+ >
191
+ <option value="all">All Status</option>
192
+ <option value="breached">Breached</option>
193
+ <option value="warning">Warning</option>
194
+ <option value="active">Active</option>
195
+ <option value="met">Met</option>
196
+ </select>
197
+ </div>
198
+ <div className="flex items-center gap-2 px-3 py-1.5 bg-white border border-gray-200 rounded-lg">
199
+ <Filter size={14} className="text-gray-400" />
200
+ <select
201
+ value={filterPriority}
202
+ onChange={e => setFilterPriority(e.target.value)}
203
+ className="text-xs font-semibold text-gray-600 bg-transparent border-none outline-none cursor-pointer"
204
+ >
205
+ <option value="all">All Priorities</option>
206
+ <option value="critical">Critical</option>
207
+ <option value="high">High</option>
208
+ <option value="medium">Medium</option>
209
+ <option value="low">Low</option>
210
+ </select>
211
+ </div>
212
+ <button
213
+ onClick={loadData}
214
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-gray-500 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
215
+ >
216
+ <RefreshCw size={12} /> Refresh
217
+ </button>
218
+ </div>
219
+ );
220
+
221
+ // ── Render: Dashboard Tab ───────────────────────────────────────────────────
222
+
223
+ const renderDashboard = () => <SLADashboard />;
224
+
225
+ // ── Render: Violations Tab ──────────────────────────────────────────────────
226
+
227
+ const renderViolations = () => {
228
+ if (loading) {
229
+ return (
230
+ <div className="py-24 text-center">
231
+ <RefreshCw size={32} className="animate-spin mx-auto mb-4 text-emerald-500" />
232
+ <p className="text-sm text-gray-400 font-semibold">Loading violations...</p>
233
+ </div>
234
+ );
235
+ }
236
+
237
+ const activeViolations = filteredTickets.filter(
238
+ t => t.sla_status === 'breached' || t.sla_status === 'warning'
239
+ );
240
+
241
+ if (activeViolations.length === 0) {
242
+ return (
243
+ <div className="py-24 text-center" style={{ border: '2px dashed #D1FAE5', borderRadius: '16px' }}>
244
+ <ShieldCheck size={48} className="mx-auto mb-3 text-emerald-400" />
245
+ <h3 className="text-lg font-bold text-emerald-700">No Active Violations</h3>
246
+ <p className="text-sm text-gray-500 mt-1">All tickets within SLA limits.</p>
247
+ </div>
248
+ );
249
+ }
250
+
251
+ return (
252
+ <div className="space-y-3">
253
+ {activeViolations.map(ticket => {
254
+ const pStyle = PRIORITY_STYLES[ticket.priority?.toLowerCase()] || PRIORITY_STYLES.medium;
255
+ const sStyle = STATUS_STYLES[ticket.sla_status] || STATUS_STYLES.active;
256
+
257
+ return (
258
+ <div
259
+ key={ticket.id}
260
+ className="flex items-center justify-between p-4 rounded-xl border bg-white transition-all hover:shadow-md"
261
+ style={{ borderColor: sStyle.border }}
262
+ >
263
+ <div className="flex items-center gap-4 min-w-0 flex-1">
264
+ <sStyle.icon size={20} style={{ color: sStyle.text }} className="flex-shrink-0" />
265
+ <div className="min-w-0 flex-1">
266
+ <div className="flex items-center gap-2">
267
+ <span className="text-sm font-semibold text-gray-800 truncate">
268
+ {ticket.subject || ticket.summary || 'Untitled'}
269
+ </span>
270
+ <span
271
+ className="text-[10px] font-bold px-2 py-0.5 rounded uppercase"
272
+ style={{ background: pStyle.bg, color: pStyle.text, border: `1px solid ${pStyle.border}` }}
273
+ >
274
+ {ticket.priority || 'MEDIUM'}
275
+ </span>
276
+ </div>
277
+ <div className="flex items-center gap-3 mt-1">
278
+ <span className="text-xs text-gray-400">
279
+ #{((ticket.ticket_id || ticket.id) || '').toString().slice(0, 8)}
280
+ </span>
281
+ <span className="text-xs text-gray-400">{ticket.assigned_team || 'Unassigned'}</span>
282
+ <span className="text-xs text-gray-400">
283
+ {formatDuration(ticket.remaining_seconds)}
284
+ </span>
285
+ </div>
286
+ </div>
287
+ </div>
288
+ <div className="flex items-center gap-2 flex-shrink-0">
289
+ <SLABadge
290
+ priority={ticket.priority}
291
+ createdAt={ticket.created_at}
292
+ status={ticket.status}
293
+ compact
294
+ />
295
+ <a
296
+ href={`/admin/ticket/${ticket.ticket_id || ticket.id}`}
297
+ className="p-2 text-gray-400 hover:text-emerald-500 transition-colors"
298
+ >
299
+ <ExternalLink size={14} />
300
+ </a>
301
+ </div>
302
+ </div>
303
+ );
304
+ })}
305
+ </div>
306
+ );
307
+ };
308
+
309
+ // ── Render: Escalations Tab ─────────────────────────────────────────────────
310
+
311
+ const renderEscalations = () => {
312
+ if (loading) {
313
+ return (
314
+ <div className="py-24 text-center">
315
+ <RefreshCw size={32} className="animate-spin mx-auto mb-4 text-emerald-500" />
316
+ <p className="text-sm text-gray-400 font-semibold">Loading escalations...</p>
317
+ </div>
318
+ );
319
+ }
320
+
321
+ if (escalations.length === 0) {
322
+ return (
323
+ <div className="py-24 text-center" style={{ border: '2px dashed #E5E7EB', borderRadius: '16px' }}>
324
+ <Bell size={40} className="mx-auto mb-3 text-gray-300" />
325
+ <h3 className="text-sm font-bold text-gray-500">No Escalations Logged</h3>
326
+ <p className="text-xs text-gray-400 mt-1">All tickets resolved within SLA.</p>
327
+ </div>
328
+ );
329
+ }
330
+
331
+ return (
332
+ <div className="space-y-2">
333
+ {escalations.map((esc, idx) => {
334
+ const isBreached = esc.sla_status === 'breached';
335
+ const isExpanded = expandedEsc === (esc.id || idx);
336
+ const level = esc.escalation_level || 0;
337
+
338
+ return (
339
+ <div
340
+ key={esc.id || idx}
341
+ className="rounded-xl border bg-white overflow-hidden transition-all"
342
+ style={{ borderColor: isBreached ? '#FECACA' : '#E5E7EB' }}
343
+ >
344
+ <button
345
+ onClick={() => setExpandedEsc(isExpanded ? null : (esc.id || idx))}
346
+ className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 transition-colors"
347
+ >
348
+ <div className="flex items-center gap-3 min-w-0 flex-1">
349
+ <div
350
+ className="w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0"
351
+ style={{ background: isBreached ? '#FEF2F2' : '#FEFCE8' }}
352
+ >
353
+ {isBreached
354
+ ? <AlertCircle size={16} className="text-red-500" />
355
+ : <AlertTriangle size={16} className="text-amber-500" />
356
+ }
357
+ </div>
358
+ <div className="min-w-0 flex-1">
359
+ <p className="text-sm font-semibold text-gray-800 truncate">
360
+ {esc.ticket_subject || `Ticket #${((esc.ticket_id || '') + '').slice(0, 8)}`}
361
+ </p>
362
+ <div className="flex items-center gap-2 mt-0.5">
363
+ <span className="text-[10px] font-bold uppercase px-1.5 py-0.5 rounded"
364
+ style={{
365
+ background: isBreached ? '#FEF2F2' : '#FEFCE8',
366
+ color: isBreached ? '#DC2626' : '#CA8A04',
367
+ }}
368
+ >
369
+ L{level}
370
+ </span>
371
+ <span className="text-xs text-gray-400">{esc.assigned_team || 'Unassigned'}</span>
372
+ <span className="text-xs text-gray-400">{formatDate(esc.triggered_at)}</span>
373
+ </div>
374
+ </div>
375
+ </div>
376
+ <div className="flex items-center gap-2 flex-shrink-0">
377
+ <span
378
+ className={`text-[10px] font-bold px-2 py-1 rounded-full uppercase ${
379
+ isBreached ? 'bg-red-50 text-red-600' : 'bg-amber-50 text-amber-600'
380
+ }`}
381
+ >
382
+ {esc.sla_status}
383
+ </span>
384
+ {isExpanded ? <ChevronUp size={14} className="text-gray-400" /> : <ChevronDown size={14} className="text-gray-400" />}
385
+ </div>
386
+ </button>
387
+
388
+ {isExpanded && (
389
+ <div className="px-4 pb-4 pt-0 border-t border-gray-100 bg-gray-50/50">
390
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-3">
391
+ <div>
392
+ <p className="text-[10px] font-semibold text-gray-400 uppercase">Status</p>
393
+ <p className="text-xs font-bold text-gray-700 mt-1">{esc.sla_status}</p>
394
+ </div>
395
+ <div>
396
+ <p className="text-[10px] font-semibold text-gray-400 uppercase">Level</p>
397
+ <p className="text-xs font-bold text-gray-700 mt-1">{level}</p>
398
+ </div>
399
+ <div>
400
+ <p className="text-[10px] font-semibold text-gray-400 uppercase">Remaining</p>
401
+ <p className="text-xs font-bold text-gray-700 mt-1">{formatDuration(esc.remaining_seconds)}</p>
402
+ </div>
403
+ <div>
404
+ <p className="text-[10px] font-semibold text-gray-400 uppercase">Priority</p>
405
+ <p className="text-xs font-bold text-gray-700 mt-1">{esc.priority || 'N/A'}</p>
406
+ </div>
407
+ </div>
408
+ {esc.notification_channels && esc.notification_channels.length > 0 && (
409
+ <div className="mt-3 flex items-center gap-2">
410
+ <span className="text-[10px] font-semibold text-gray-400 uppercase">Channels:</span>
411
+ {esc.notification_channels.map((ch, i) => (
412
+ <span
413
+ key={i}
414
+ className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${
415
+ ch.includes('OK') ? 'bg-emerald-50 text-emerald-600' : 'bg-red-50 text-red-500'
416
+ }`}
417
+ >
418
+ {ch}
419
+ </span>
420
+ ))}
421
+ </div>
422
+ )}
423
+ </div>
424
+ )}
425
+ </div>
426
+ );
427
+ })}
428
+ </div>
429
+ );
430
+ };
431
+
432
+ // ── Render: Policies Tab ────────────────────────────────────────────────────
433
+
434
+ const renderPolicies = () => {
435
+ if (policies.length === 0) {
436
+ return (
437
+ <div className="py-16 text-center" style={{ border: '2px dashed #E5E7EB', borderRadius: '16px' }}>
438
+ <Settings size={40} className="mx-auto mb-3 text-gray-300" />
439
+ <h3 className="text-sm font-bold text-gray-500">Default Policies Active</h3>
440
+ <p className="text-xs text-gray-400 mt-1">Custom policies can be configured from the database.</p>
441
+ </div>
442
+ );
443
+ }
444
+
445
+ return (
446
+ <div className="overflow-x-auto">
447
+ <table className="w-full border-collapse">
448
+ <thead>
449
+ <tr style={{ background: '#F8FAFC', borderBottom: '1px solid #E2E8F0' }}>
450
+ <th className="text-[10px] font-bold text-gray-500 uppercase tracking-wider p-4 text-left">Priority</th>
451
+ <th className="text-[10px] font-bold text-gray-500 uppercase tracking-wider p-4 text-left">Max Hours</th>
452
+ <th className="text-[10px] font-bold text-gray-500 uppercase tracking-wider p-4 text-left">Warning At</th>
453
+ <th className="text-[10px] font-bold text-gray-500 uppercase tracking-wider p-4 text-left">Auto Escalate</th>
454
+ <th className="text-[10px] font-bold text-gray-500 uppercase tracking-wider p-4 text-left">L2 Escalation</th>
455
+ <th className="text-[10px] font-bold text-gray-500 uppercase tracking-wider p-4 text-left">L3 Escalation</th>
456
+ </tr>
457
+ </thead>
458
+ <tbody>
459
+ {policies.map((p, idx) => {
460
+ const colors = PRIORITY_STYLES[p.priority] || PRIORITY_STYLES.medium;
461
+ return (
462
+ <tr key={p.id || idx} className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
463
+ <td className="p-4">
464
+ <span
465
+ className="text-xs font-bold uppercase px-3 py-1 rounded-full"
466
+ style={{ background: colors.bg, color: colors.text, border: `1px solid ${colors.border}` }}
467
+ >
468
+ {p.priority}
469
+ </span>
470
+ </td>
471
+ <td className="p-4 text-sm font-semibold text-gray-700">{p.max_hours}h</td>
472
+ <td className="p-4 text-sm font-semibold text-gray-700">{Math.round((p.warning_pct || 0.75) * 100)}%</td>
473
+ <td className="p-4">
474
+ <span
475
+ className={`text-xs font-bold px-2 py-0.5 rounded ${
476
+ p.auto_escalate ? 'bg-emerald-50 text-emerald-600' : 'bg-gray-100 text-gray-500'
477
+ }`}
478
+ >
479
+ {p.auto_escalate ? 'Yes' : 'No'}
480
+ </span>
481
+ </td>
482
+ <td className="p-4 text-sm text-gray-600">{p.l2_after_minutes > 0 ? `${p.l2_after_minutes}m` : 'Immediate'}</td>
483
+ <td className="p-4 text-sm text-gray-600">{p.l3_after_minutes > 0 ? `${p.l3_after_minutes}m` : '—'}</td>
484
+ </tr>
485
+ );
486
+ })}
487
+ </tbody>
488
+ </table>
489
+ </div>
490
+ );
491
+ };
492
+
493
+ // ── Main Render ────────────────────────────────────────────────────────────
494
+
495
+ return (
496
+ <div style={{ background: '#F8FAF9', minHeight: '100vh', paddingBottom: '60px' }} className="space-y-8 -m-6 p-6 md:-m-10 md:p-10">
497
+ {/* Header */}
498
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
499
+ <div>
500
+ <h1 className="text-2xl font-extrabold text-gray-900" style={{ fontFamily: 'Syne, sans-serif' }}>
501
+ SLA Monitor
502
+ </h1>
503
+ <p className="text-sm text-gray-500 mt-1 flex items-center gap-2">
504
+ <span className="w-2 h-2 rounded-full bg-emerald-500 inline-block animate-pulse" />
505
+ Service-Level Agreement Management
506
+ </p>
507
+ </div>
508
+ <FiltersBar />
509
+ </div>
510
+
511
+ {/* Tab Navigation */}
512
+ <div className="flex gap-1 p-1 bg-white rounded-xl border border-gray-100 w-fit">
513
+ {tabs.map(tab => (
514
+ <button
515
+ key={tab.id}
516
+ onClick={() => setActiveTab(tab.id)}
517
+ className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${
518
+ activeTab === tab.id
519
+ ? 'bg-emerald-500 text-white shadow-sm'
520
+ : 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
521
+ }`}
522
+ >
523
+ <tab.icon size={14} />
524
+ {tab.label}
525
+ </button>
526
+ ))}
527
+ </div>
528
+
529
+ {/* Tab Content */}
530
+ <div>
531
+ {activeTab === 'dashboard' && renderDashboard()}
532
+ {activeTab === 'violations' && renderViolations()}
533
+ {activeTab === 'escalations' && renderEscalations()}
534
+ {activeTab === 'policies' && renderPolicies()}
535
+ </div>
536
+ </div>
537
+ );
538
+ }
Frontend/src/components/shared/DuplicateWarningBanner.jsx ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * DuplicateWarningBanner — Animated warning card shown when a potential
3
+ * duplicate ticket is detected during ticket creation.
4
+ *
5
+ * Features:
6
+ * - Animated slide-in warning with similarity score
7
+ * - Link to parent ticket
8
+ * - "Subscribe to existing" vs "Create anyway" actions
9
+ * - Matches the HELPDESK.AI design system (glass morphism, gradient)
10
+ */
11
+
12
+ import React, { useState, useEffect } from 'react';
13
+ import { useNavigate } from 'react-router-dom';
14
+ import {
15
+ AlertTriangle,
16
+ Copy,
17
+ Eye,
18
+ Bell,
19
+ X,
20
+ ExternalLink,
21
+ ChevronDown,
22
+ ChevronUp,
23
+ Users,
24
+ Clock,
25
+ ArrowRight,
26
+ Send,
27
+ } from 'lucide-react';
28
+
29
+ /**
30
+ * Format similarity as percentage with color.
31
+ */
32
+ function formatSimilarity(score) {
33
+ if (score == null) return '—';
34
+ const pct = Math.round(score * 100);
35
+ if (pct >= 95) return { value: `${pct}%`, color: '#DC2626' };
36
+ if (pct >= 85) return { value: `${pct}%`, color: '#EA580C' };
37
+ if (pct >= 75) return { value: `${pct}%`, color: '#CA8A04' };
38
+ return { value: `${pct}%`, color: '#16A34A' };
39
+ }
40
+
41
+ /**
42
+ * Format an ISO date string to a relative time.
43
+ */
44
+ function formatDate(iso) {
45
+ if (!iso) return '';
46
+ const d = new Date(iso);
47
+ const now = new Date();
48
+ const diffMs = now - d;
49
+ const diffHr = Math.floor(diffMs / (1000 * 60 * 60));
50
+ if (diffHr < 1) return 'just now';
51
+ if (diffHr < 24) return `${diffHr}h ago`;
52
+ return `${Math.floor(diffHr / 24)}d ago`;
53
+ }
54
+
55
+ /**
56
+ * DuplicateWarningBanner
57
+ *
58
+ * Props:
59
+ * - duplicate: { is_duplicate, duplicate_ticket_id, parent_subject, similarity, candidates }
60
+ * - onSubscribe: () => void — User wants to subscribe to existing ticket
61
+ * - onCreateAnyway: () => void — User wants to create ticket regardless
62
+ * - onDismiss: () => void — Dismiss warning
63
+ * - ticketId: string — ID of the current ticket being created
64
+ */
65
+ export default function DuplicateWarningBanner({
66
+ duplicate,
67
+ onSubscribe,
68
+ onCreateAnyway,
69
+ onDismiss,
70
+ ticketId,
71
+ }) {
72
+ const navigate = useNavigate();
73
+ const [expanded, setExpanded] = useState(false);
74
+ const [visible, setVisible] = useState(false);
75
+
76
+ // Animate in on mount
77
+ useEffect(() => {
78
+ const timer = setTimeout(() => setVisible(true), 100);
79
+ return () => clearTimeout(timer);
80
+ }, []);
81
+
82
+ if (!duplicate || !duplicate.is_duplicate) return null;
83
+
84
+ const { similarity, parent_subject, duplicate_ticket_id, candidates } = duplicate;
85
+ const sim = formatSimilarity(similarity);
86
+ const extraCandidates = (candidates || []).filter(
87
+ c => c.id !== duplicate_ticket_id
88
+ );
89
+
90
+ return (
91
+ <div
92
+ className={`transition-all duration-500 ease-out ${
93
+ visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'
94
+ }`}
95
+ >
96
+ <div
97
+ className="rounded-2xl border-2 overflow-hidden"
98
+ style={{
99
+ borderColor: '#FECACA',
100
+ background: 'linear-gradient(135deg, #FFF5F5 0%, #FFFBEB 100%)',
101
+ boxShadow: '0 8px 32px rgba(239, 68, 68, 0.1)',
102
+ }}
103
+ >
104
+ {/* Warning Header */}
105
+ <div className="p-5 flex items-start gap-4">
106
+ {/* Icon */}
107
+ <div
108
+ className="w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 animate-pulse"
109
+ style={{ background: '#FEF2F2', border: '1px solid #FECACA' }}
110
+ >
111
+ <AlertTriangle size={22} className="text-red-500" />
112
+ </div>
113
+
114
+ {/* Content */}
115
+ <div className="flex-1 min-w-0">
116
+ <div className="flex items-start justify-between gap-2">
117
+ <div>
118
+ <h3 className="text-base font-bold text-gray-900 flex items-center gap-2">
119
+ <Copy size={16} className="text-amber-500" />
120
+ Potential Duplicate Detected
121
+ </h3>
122
+ <p className="text-sm text-gray-600 mt-1">
123
+ A similar issue was recently reported by your teammate.
124
+ </p>
125
+ </div>
126
+ {onDismiss && (
127
+ <button
128
+ onClick={onDismiss}
129
+ className="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors flex-shrink-0"
130
+ >
131
+ <X size={16} />
132
+ </button>
133
+ )}
134
+ </div>
135
+
136
+ {/* Match Info Card */}
137
+ <div
138
+ className="mt-4 rounded-xl p-4 border"
139
+ style={{ background: '#FFFFFF', borderColor: '#FEE2E2' }}
140
+ >
141
+ <div className="flex items-center justify-between">
142
+ <div className="flex items-center gap-3 min-w-0 flex-1">
143
+ {/* Similarity Badge */}
144
+ <div
145
+ className="flex flex-col items-center px-3 py-2 rounded-lg"
146
+ style={{ background: similarity >= 0.85 ? '#FEF2F2' : '#FFFBEB' }}
147
+ >
148
+ <span
149
+ className="text-lg font-extrabold"
150
+ style={{ color: sim.color }}
151
+ >
152
+ {sim.value}
153
+ </span>
154
+ <span className="text-[9px] font-semibold text-gray-400 uppercase tracking-wider">
155
+ Match
156
+ </span>
157
+ </div>
158
+
159
+ {/* Parent Ticket Info */}
160
+ <div className="min-w-0 flex-1">
161
+ {parent_subject && (
162
+ <p className="text-sm font-semibold text-gray-800 truncate">
163
+ {parent_subject}
164
+ </p>
165
+ )}
166
+ <p className="text-xs text-gray-500 mt-0.5">
167
+ Ticket #{((duplicate_ticket_id || '') + '').slice(0, 8).toUpperCase()}
168
+ </p>
169
+ </div>
170
+ </div>
171
+
172
+ {/* View Parent Ticket */}
173
+ <a
174
+ href={`/admin/ticket/${duplicate_ticket_id}`}
175
+ className="flex items-center gap-1.5 px-3 py-2 text-xs font-bold text-blue-600 bg-blue-50 border border-blue-200 rounded-lg hover:bg-blue-100 transition-colors flex-shrink-0"
176
+ >
177
+ <Eye size={14} />
178
+ View
179
+ </a>
180
+ </div>
181
+
182
+ {/* Extra candidates */}
183
+ {extraCandidates.length > 0 && (
184
+ <div className="mt-3 pt-3 border-t border-gray-100">
185
+ <button
186
+ onClick={() => setExpanded(!expanded)}
187
+ className="flex items-center gap-1.5 text-xs font-semibold text-gray-500 hover:text-gray-700 transition-colors"
188
+ >
189
+ {expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
190
+ {extraCandidates.length} more similar ticket{extraCandidates.length > 1 ? 's' : ''}
191
+ </button>
192
+
193
+ {expanded && (
194
+ <div className="mt-2 space-y-1.5">
195
+ {extraCandidates.map((c, idx) => (
196
+ <div
197
+ key={c.id || idx}
198
+ className="flex items-center justify-between p-2 rounded-lg bg-gray-50"
199
+ >
200
+ <div className="flex items-center gap-2 min-w-0">
201
+ <span
202
+ className="text-[10px] font-bold px-1.5 py-0.5 rounded"
203
+ style={{
204
+ background: c.similarity >= 0.85 ? '#FEF2F2' : '#FEFCE8',
205
+ color: c.similarity >= 0.85 ? '#DC2626' : '#CA8A04',
206
+ }}
207
+ >
208
+ {Math.round(c.similarity * 100)}%
209
+ </span>
210
+ <span className="text-xs text-gray-700 truncate">
211
+ {c.subject || `#${((c.id || '') + '').slice(0, 8)}`}
212
+ </span>
213
+ </div>
214
+ <span className="text-[10px] text-gray-400 flex-shrink-0">
215
+ {c.assigned_team || ''}
216
+ </span>
217
+ </div>
218
+ ))}
219
+ </div>
220
+ )}
221
+ </div>
222
+ )}
223
+ </div>
224
+
225
+ {/* Action Buttons */}
226
+ <div className="flex flex-wrap items-center gap-3 mt-4">
227
+ {onSubscribe && (
228
+ <button
229
+ onClick={onSubscribe}
230
+ className="flex items-center gap-2 px-5 py-2.5 text-sm font-bold text-white rounded-xl transition-all hover:shadow-lg active:scale-[0.98]"
231
+ style={{ background: 'linear-gradient(135deg, #16A34A, #15803D)' }}
232
+ >
233
+ <Bell size={16} />
234
+ Subscribe to Existing Ticket
235
+ <ArrowRight size={16} />
236
+ </button>
237
+ )}
238
+ {onCreateAnyway && (
239
+ <button
240
+ onClick={onCreateAnyway}
241
+ className="flex items-center gap-2 px-4 py-2.5 text-sm font-semibold text-gray-600 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 hover:border-gray-300 transition-all"
242
+ >
243
+ <Send size={14} />
244
+ Create Anyway
245
+ </button>
246
+ )}
247
+ <p className="text-[10px] text-gray-400 ml-auto">
248
+ AI-powered semantic match · all-MiniLM-L6-v2
249
+ </p>
250
+ </div>
251
+ </div>
252
+ </div>
253
+ </div>
254
+ </div>
255
+ );
256
+ }
backend/main.py CHANGED
@@ -12,6 +12,7 @@ import datetime
12
  import traceback
13
  import warnings
14
  import logging
 
15
  from contextlib import asynccontextmanager
16
 
17
  # Suppress harmless PyTorch CPU pin_memory warning
@@ -60,13 +61,8 @@ from backend.services.onnx_service import onnx_classifier
60
  from backend.services.ner_service import NERService
61
  from backend.services.duplicate_service import DuplicateService
62
  from backend.services.rag_service import RagService
63
- from backend.services.sla_service import (
64
- calculate_sla_breach_at,
65
- calculate_sla_response_at,
66
- classify_sla_status,
67
- load as load_sla_service,
68
- run_sla_escalation_loop,
69
- )
70
 
71
 
72
  # ---------------------------------------------------------------------------
@@ -154,13 +150,13 @@ def classify_ticket_text(text: str) -> dict:
154
  "category": "Unknown", "subcategory": "Unknown", "priority": "Medium",
155
  "auto_resolve": False, "assigned_team": "General Support", "confidence": 0.0,
156
  }
 
157
  class TicketRequest(BaseModel):
158
  text: str
159
  image_base64: str = ""
160
  image_text: str = "" # Keep for backward compatibility
161
  user_id: str | None = None
162
  company: str | None = None
163
- company_id: str | None = None
164
  image_url: str | None = None
165
  confidence_threshold: float = 0.20
166
  duplicate_sensitivity: float = 0.85
@@ -180,10 +176,6 @@ class TicketSaveRequest(BaseModel):
180
  image_url: str | None = None
181
  company: str | None = None
182
  company_id: str | None = None
183
- description_vector: list[float] | None = None
184
- is_potential_duplicate: bool = False
185
- parent_ticket_id: str | None = None
186
- sla_response_due_at: str | None = None
187
  sla_breach_at: str
188
  sla_status: str | None = None
189
  escalation_level: int = 0
@@ -199,8 +191,6 @@ class TicketSaveRequest(BaseModel):
199
  class DuplicateInfo(BaseModel):
200
  is_duplicate: bool
201
  duplicate_ticket_id: str | None = None
202
- parent_ticket_id: str | None = None
203
- is_potential_duplicate: bool = False
204
  similarity: float = 0.0
205
 
206
 
@@ -222,8 +212,6 @@ class TicketResponse(BaseModel):
222
  entities: list[EntityInfo]
223
  duplicate_ticket: DuplicateInfo
224
  confidence: float
225
- is_potential_duplicate: bool = False
226
- parent_ticket_id: str | None = None
227
  needs_review: bool = False
228
  reasoning: str = ""
229
  decision_factors: list[str] = []
@@ -232,7 +220,6 @@ class TicketResponse(BaseModel):
232
  highlights: list[str] = []
233
  timeline: dict = {} # Map of step_name: timestamp
234
  env_metadata: dict = {} # IP, Hostname, Browser/OS
235
- sla_breach_at: str | None = None
236
  version: str = "2.1.0-Neural-Diagnostic"
237
 
238
 
@@ -300,6 +287,8 @@ classifier_service = ClassifierService()
300
  ner_service = NERService()
301
  duplicate_service = DuplicateService()
302
  rag_service = RagService()
 
 
303
 
304
  try:
305
  from backend.services.gemini_service import GeminiService
@@ -347,45 +336,29 @@ async def lifespan(app: FastAPI):
347
  else:
348
  print("[Startup] Gemini Service: NOT LOADED (Import failed)")
349
 
350
- print("[Startup] Classifier V2 Shadow: Ready.")
351
- print(f"[Startup] ONNX MiniLM Fallback: {'READY' if getattr(onnx_classifier, '_loaded', False) else 'DEGRADED (artifacts missing)'}")
352
- print("[Startup] Ready.")
353
- # Strict health checks: fail loudly when core model assets are unavailable.
354
- # Set ALLOW_DEGRADED_STARTUP=1 to permit degraded startup for local/dev convenience.
355
- try:
356
- strict_mode = os.environ.get("ALLOW_DEGRADED_STARTUP", "0") != "1"
357
- except Exception:
358
- strict_mode = True
359
 
360
- classifier_loaded_flag = getattr(classifier_service, "_loaded", False)
361
- ner_loaded_flag = getattr(ner_service, "_loaded", False)
 
 
 
 
 
362
 
363
- if strict_mode and not classifier_loaded_flag:
364
- raise RuntimeError("[Startup-FATAL] Classifier assets not loaded. Set ALLOW_DEGRADED_STARTUP=1 to bypass.")
 
 
 
365
 
366
- sla_task = None
367
- try:
368
- if supabase and os.environ.get("SLA_ESCALATION_ENABLED", "true").lower() == "true":
369
- notification_router = None
370
- try:
371
- from backend.services.notification_routing import load as load_notification_router
372
- notification_router = load_notification_router()
373
- except Exception as e:
374
- print(f"[WARNING] Notification router not loaded for SLA service: {e}")
375
- sla_service = load_sla_service(supabase, notification_router)
376
- interval = int(os.environ.get("SLA_ESCALATION_INTERVAL_SECONDS", "300"))
377
- sla_task = asyncio.create_task(run_sla_escalation_loop(sla_service, interval_seconds=interval))
378
- print(f"[Startup] SLA escalation loop enabled ({interval}s interval).")
379
-
380
- yield
381
- finally:
382
- if sla_task:
383
- sla_task.cancel()
384
- try:
385
- await sla_task
386
- except asyncio.CancelledError:
387
- pass
388
- print("[Shutdown] Cleaning up ...")
389
 
390
 
391
  # ---------------------------------------------------------------------------
@@ -514,29 +487,18 @@ async def health_check():
514
  @app.get("/ready", response_model=ReadinessResponse)
515
  async def readiness_check():
516
  require_supabase = os.environ.get("REQUIRE_SUPABASE", "false").lower() == "true"
517
- allow_degraded = os.environ.get("ALLOW_DEGRADED_STARTUP", "0") == "1"
518
-
519
  checks = {
520
  "api": True,
521
  "classifier_loaded": classifier_service._loaded,
522
  "ner_loaded": ner_service._loaded,
523
- "duplicate_index_loaded": duplicate_service.is_available(),
524
- "rag_loaded": rag_service.is_available(),
525
  }
526
  if require_supabase:
527
  checks["supabase_configured"] = supabase is not None
528
 
529
- # In degraded mode, duplicate and RAG services are optional
530
- if allow_degraded:
531
- required_checks = {k: v for k, v in checks.items() if k not in ["duplicate_index_loaded", "rag_loaded"]}
532
- all_required_pass = all(required_checks.values())
533
-
534
- if all_required_pass:
535
- return ReadinessResponse(status="ready", checks=checks)
536
- else:
537
- # Strict mode: all checks must pass
538
- if all(checks.values()):
539
- return ReadinessResponse(status="ready", checks=checks)
540
 
541
  return JSONResponse(
542
  status_code=503,
@@ -677,31 +639,6 @@ async def get_tickets(company_id: str | None = None):
677
  res = query.execute()
678
  return res.data
679
 
680
- @app.get("/tickets/search")
681
- async def search_tickets(q: str | None = None, company_id: str | None = None, limit: int = 50, offset: int = 0):
682
- """Search tickets using tenant-safe full-text search."""
683
- if not supabase:
684
- raise HTTPException(status_code=500, detail="Database connection not initialized")
685
-
686
- if not q:
687
- raise HTTPException(status_code=400, detail="Search query is required")
688
- if not company_id:
689
- raise HTTPException(status_code=400, detail="company_id is required for tenant-safe search")
690
-
691
- try:
692
- result = supabase.rpc(
693
- "search_tickets",
694
- {
695
- "query_text": q,
696
- "company_id": company_id,
697
- "limit_rows": limit,
698
- "offset_rows": offset,
699
- },
700
- ).execute()
701
- return result.data or []
702
- except Exception as e:
703
- raise HTTPException(status_code=500, detail=f"Search failed: {e}")
704
-
705
  @app.post("/tickets/save")
706
  async def save_ticket(request_body: TicketSaveRequest):
707
  """
@@ -753,38 +690,28 @@ async def save_ticket(request_body: TicketSaveRequest):
753
  if not final_data.get("company") and profile.get("company"):
754
  final_data["company"] = profile["company"]
755
 
756
- priority = final_data.get("priority")
757
- if not final_data.get("sla_response_due_at"):
758
- final_data["sla_response_due_at"] = calculate_sla_response_at(priority).isoformat().replace("+00:00", "Z")
759
- if not final_data.get("sla_breach_at"):
760
- final_data["sla_breach_at"] = calculate_sla_breach_at(priority).isoformat().replace("+00:00", "Z")
761
- final_data["sla_status"] = final_data.get("sla_status") or classify_sla_status(final_data.get("sla_breach_at"))
762
- final_data["escalation_level"] = int(final_data.get("escalation_level") or 0)
763
-
764
- logger.info(f"Tenant linkage: user_id={request_body.user_id}, company_id={final_data.get('company_id')}")
765
-
766
- duplicate_text = (request_body.description or "").strip() or (request_body.subject or "").strip()
767
- duplicate_threshold = get_duplicate_threshold(final_data.get("company_id"), 0.85)
768
- duplicate_result = {
769
- "is_duplicate": False,
770
- "duplicate_ticket_id": None,
771
- "parent_ticket_id": None,
772
- "is_potential_duplicate": False,
773
- "similarity": 0.0,
774
- }
775
 
776
- if duplicate_text:
777
- duplicate_result = detect_semantic_duplicate(
778
- duplicate_text,
779
- company_id=final_data.get("company_id"),
780
- threshold=duplicate_threshold,
781
- )
782
- final_data["description_vector"] = duplicate_service.generate_embedding(duplicate_text)
783
- else:
784
- final_data["description_vector"] = None
785
 
786
- final_data["is_potential_duplicate"] = duplicate_result.get("is_potential_duplicate", False)
787
- final_data["parent_ticket_id"] = duplicate_result.get("parent_ticket_id")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
788
 
789
  # --- Sanitize payload to only include valid Supabase DB columns ---
790
  # Extra AI telemetry and non-existent schema fields are merged into the metadata JSONB column
@@ -815,19 +742,29 @@ async def save_ticket(request_body: TicketSaveRequest):
815
 
816
  ticket_id = res.data[0]["id"]
817
 
818
- duplicate_indexed = True
819
- duplicate_index_warning = None
 
 
 
 
 
 
 
 
 
 
 
 
 
820
  if duplicate_text:
821
  try:
 
822
  duplicate_service.add_ticket(str(ticket_id), duplicate_text)
 
 
823
  except Exception as index_error:
824
- duplicate_indexed = False
825
- duplicate_index_warning = "Duplicate index update failed."
826
- print(f"[WARNING] {duplicate_index_warning} ticket_id={ticket_id} error={index_error}")
827
- else:
828
- duplicate_indexed = False
829
- duplicate_index_warning = "Duplicate index update skipped: no description or subject text was provided."
830
- print(f"[WARNING] {duplicate_index_warning}")
831
 
832
  # Add initial system diagnostic message
833
  msg = "Our Neural Engine has successfully triaged your issue and routed it to the designated team."
@@ -845,12 +782,14 @@ async def save_ticket(request_body: TicketSaveRequest):
845
  response = {
846
  "status": "success",
847
  "ticket_id": ticket_id,
848
- "duplicate_indexed": duplicate_indexed,
849
- "is_potential_duplicate": final_data["is_potential_duplicate"],
850
- "parent_ticket_id": final_data["parent_ticket_id"],
851
  }
852
- if duplicate_index_warning:
853
- response["duplicate_index_warning"] = duplicate_index_warning
 
 
 
 
854
  return response
855
 
856
  except Exception as e:
@@ -920,11 +859,6 @@ async def analyze_ticket(request_body: TicketRequest, request: Request):
920
  Main endpoint for analyzing a new ticket using the cascade of local AI models.
921
  """
922
  text = request_body.text
923
-
924
- settings = get_system_settings(request_body.company_id)
925
- confidence_threshold = settings["ai_confidence_threshold"]
926
- duplicate_sensitivity = settings["duplicate_sensitivity"]
927
- enable_auto_resolve = settings["enable_auto_resolve"]
928
 
929
  # Grab client metadata
930
  client_ip = request.client.host if request.client else "unknown"
@@ -1047,21 +981,10 @@ async def analyze_only(request_body: TicketRequest):
1047
  timeline["metadata_harvested"] = get_now_ist()
1048
 
1049
  # --- Duplicate detection ---
1050
- duplicate_threshold = get_duplicate_threshold(request_body.company_id, duplicate_sensitivity)
1051
  try:
1052
- dup_result = detect_semantic_duplicate(
1053
- text,
1054
- company_id=request_body.company_id,
1055
- threshold=duplicate_threshold,
1056
- )
1057
  except Exception:
1058
- dup_result = {
1059
- "is_duplicate": False,
1060
- "duplicate_ticket_id": None,
1061
- "parent_ticket_id": None,
1062
- "is_potential_duplicate": False,
1063
- "similarity": 0.0,
1064
- }
1065
 
1066
  # --- RAG Knowledge Base Check ---
1067
  rag_match = None
@@ -1077,7 +1000,7 @@ async def analyze_only(request_body: TicketRequest):
1077
 
1078
  # --- Reasoning ---
1079
  decision_factors = []
1080
- if classification["confidence"] > confidence_threshold:
1081
  decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
1082
  if entities:
1083
  decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
@@ -1087,14 +1010,6 @@ async def analyze_only(request_body: TicketRequest):
1087
  decision_factors.append(f"Found solution article: '{rag_match['title']}'")
1088
 
1089
  reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
1090
- if (
1091
- enable_auto_resolve
1092
- and classification["confidence"] >= confidence_threshold
1093
- and classification["auto_resolve"]
1094
- ):
1095
- classification["auto_resolve"] = True
1096
- else:
1097
- classification["auto_resolve"] = False
1098
  if classification["auto_resolve"]:
1099
  reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
1100
 
@@ -1104,8 +1019,10 @@ async def analyze_only(request_body: TicketRequest):
1104
  if gemini_service and gemini_service._initialized:
1105
  summary = gemini_service.get_summary(text)
1106
 
1107
- # Convert priority to the SLA resolution target timestamp for preview.
1108
- sla_breach_dt = calculate_sla_breach_at(classification["priority"])
 
 
1109
 
1110
  return TicketResponse(
1111
  ticket_id=str(uuid.uuid4()), # Temporary ID
@@ -1118,7 +1035,7 @@ async def analyze_only(request_body: TicketRequest):
1118
  entities=[EntityInfo(**e) for e in entities],
1119
  duplicate_ticket=DuplicateInfo(**dup_result),
1120
  confidence=classification["confidence"],
1121
- needs_review=classification["confidence"] < confidence_threshold,
1122
  reasoning=reasoning,
1123
  decision_factors=decision_factors,
1124
  image_description=gemini_analysis["image_description"],
@@ -1126,9 +1043,7 @@ async def analyze_only(request_body: TicketRequest):
1126
  highlights=[e.get("text", "") for e in entities], # Use entity texts as highlights for now
1127
  timeline=timeline,
1128
  env_metadata=env_metadata,
1129
- is_potential_duplicate=dup_result.get("is_potential_duplicate", False),
1130
- parent_ticket_id=dup_result.get("parent_ticket_id"),
1131
- sla_breach_at=sla_breach_dt.isoformat().replace("+00:00", "Z")
1132
  )
1133
 
1134
  @app.post("/ai/analyze_stream")
@@ -1147,11 +1062,7 @@ async def analyze_stream(request_body: TicketRequest):
1147
  "model_version": "3.0.0-PRO",
1148
  "api_endpoint": "/ai/analyze_stream"
1149
  }
1150
- timeline = {"received": get_now_ist()}
1151
- settings = get_system_settings(request_body.company_id)
1152
- confidence_threshold = settings["ai_confidence_threshold"]
1153
- duplicate_sensitivity = settings["duplicate_sensitivity"]
1154
- enable_auto_resolve = settings["enable_auto_resolve"]
1155
 
1156
  # 1. Reading
1157
  yield f"data: {json.dumps({'step': 'Reading your message', 'status': 'in_progress'})}\n\n"
@@ -1187,20 +1098,9 @@ async def analyze_stream(request_body: TicketRequest):
1187
  yield f"data: {json.dumps({'step': 'Checking duplicate issues', 'status': 'in_progress'})}\n\n"
1188
  await asyncio.sleep(0.2)
1189
  try:
1190
- duplicate_threshold = get_duplicate_threshold(request_body.company_id, duplicate_sensitivity)
1191
- dup_result = detect_semantic_duplicate(
1192
- text,
1193
- company_id=request_body.company_id,
1194
- threshold=duplicate_threshold,
1195
- )
1196
  except Exception:
1197
- dup_result = {
1198
- "is_duplicate": False,
1199
- "duplicate_ticket_id": None,
1200
- "parent_ticket_id": None,
1201
- "is_potential_duplicate": False,
1202
- "similarity": 0.0,
1203
- }
1204
 
1205
  # 5. RAG / Solutions
1206
  yield f"data: {json.dumps({'step': 'Finding possible solutions', 'status': 'in_progress'})}\n\n"
@@ -1216,7 +1116,7 @@ async def analyze_stream(request_body: TicketRequest):
1216
  pass
1217
 
1218
  decision_factors = []
1219
- if classification["confidence"] > confidence_threshold:
1220
  decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
1221
  if entities:
1222
  decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
@@ -1225,8 +1125,6 @@ async def analyze_stream(request_body: TicketRequest):
1225
  if rag_match:
1226
  decision_factors.append(f"Found solution article: '{rag_match['title']}'")
1227
 
1228
- if not enable_auto_resolve:
1229
- classification["auto_resolve"] = False
1230
  reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
1231
  if classification["auto_resolve"]:
1232
  reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
@@ -1236,7 +1134,9 @@ async def analyze_stream(request_body: TicketRequest):
1236
  if gemini_service and gemini_service._initialized:
1237
  summary = gemini_service.get_summary(text)
1238
 
1239
- sla_breach_dt = calculate_sla_breach_at(classification["priority"])
 
 
1240
 
1241
  ticket_response_dict = {
1242
  "ticket_id": str(uuid.uuid4()),
@@ -1249,7 +1149,7 @@ async def analyze_stream(request_body: TicketRequest):
1249
  "entities": [e for e in entities],
1250
  "duplicate_ticket": dup_result,
1251
  "confidence": classification["confidence"],
1252
- "needs_review": classification["confidence"] < confidence_threshold,
1253
  "reasoning": reasoning,
1254
  "decision_factors": decision_factors,
1255
  "image_description": gemini_analysis["image_description"],
@@ -1257,9 +1157,7 @@ async def analyze_stream(request_body: TicketRequest):
1257
  "highlights": [e.get("text", "") for e in entities],
1258
  "timeline": timeline,
1259
  "env_metadata": env_metadata,
1260
- "is_potential_duplicate": dup_result.get("is_potential_duplicate", False),
1261
- "parent_ticket_id": dup_result.get("parent_ticket_id"),
1262
- "sla_breach_at": sla_breach_dt.isoformat().replace("+00:00", "Z")
1263
  }
1264
 
1265
  # 6. Final Result
@@ -1267,7 +1165,7 @@ async def analyze_stream(request_body: TicketRequest):
1267
 
1268
  return StreamingResponse(event_generator(), media_type="text/event-stream")
1269
 
1270
- @app.post("/ai/analyze_ticket/legacy")
1271
  async def legacy_analyze_and_save(request_body: TicketRequest):
1272
  """
1273
  BACKWARD COMPATIBILITY: Strictly performs analysis only.
@@ -1291,3 +1189,258 @@ async def analyze_ticket_v2(request: TicketRequest):
1291
  }
1292
  except Exception as e:
1293
  raise HTTPException(status_code=500, detail=str(e))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  import traceback
13
  import warnings
14
  import logging
15
+ import hashlib
16
  from contextlib import asynccontextmanager
17
 
18
  # Suppress harmless PyTorch CPU pin_memory warning
 
61
  from backend.services.ner_service import NERService
62
  from backend.services.duplicate_service import DuplicateService
63
  from backend.services.rag_service import RagService
64
+ from backend.services.sla_engine import SLAEngine, compute_sla_breach_at, get_sla_policy
65
+ from backend.services.semantic_duplicate_service import SemanticDuplicateService
 
 
 
 
 
66
 
67
 
68
  # ---------------------------------------------------------------------------
 
150
  "category": "Unknown", "subcategory": "Unknown", "priority": "Medium",
151
  "auto_resolve": False, "assigned_team": "General Support", "confidence": 0.0,
152
  }
153
+
154
  class TicketRequest(BaseModel):
155
  text: str
156
  image_base64: str = ""
157
  image_text: str = "" # Keep for backward compatibility
158
  user_id: str | None = None
159
  company: str | None = None
 
160
  image_url: str | None = None
161
  confidence_threshold: float = 0.20
162
  duplicate_sensitivity: float = 0.85
 
176
  image_url: str | None = None
177
  company: str | None = None
178
  company_id: str | None = None
 
 
 
 
179
  sla_breach_at: str
180
  sla_status: str | None = None
181
  escalation_level: int = 0
 
191
  class DuplicateInfo(BaseModel):
192
  is_duplicate: bool
193
  duplicate_ticket_id: str | None = None
 
 
194
  similarity: float = 0.0
195
 
196
 
 
212
  entities: list[EntityInfo]
213
  duplicate_ticket: DuplicateInfo
214
  confidence: float
 
 
215
  needs_review: bool = False
216
  reasoning: str = ""
217
  decision_factors: list[str] = []
 
220
  highlights: list[str] = []
221
  timeline: dict = {} # Map of step_name: timestamp
222
  env_metadata: dict = {} # IP, Hostname, Browser/OS
 
223
  version: str = "2.1.0-Neural-Diagnostic"
224
 
225
 
 
287
  ner_service = NERService()
288
  duplicate_service = DuplicateService()
289
  rag_service = RagService()
290
+ sla_engine = SLAEngine(supabase_client=None) # Will be reassigned after supabase init
291
+ semantic_dupe_service = SemanticDuplicateService(supabase_client=None) # wired in lifespan
292
 
293
  try:
294
  from backend.services.gemini_service import GeminiService
 
336
  else:
337
  print("[Startup] Gemini Service: NOT LOADED (Import failed)")
338
 
339
+ # Wire services with supabase client
340
+ sla_engine.supabase = supabase
341
+ semantic_dupe_service.supabase = supabase
 
 
 
 
 
 
342
 
343
+ # Pre-load embedding model so first ticket save is fast
344
+ try:
345
+ semantic_dupe_service.load()
346
+ print(f"[Startup] Semantic Duplicate Detection: {'Loaded' if semantic_dupe_service._loaded else 'Failed (model missing)'}")
347
+ except Exception as e:
348
+ print(f"[Startup] Semantic Duplicate Detection load error: {e}")
349
+ print(f"[Startup] SLA Engine: {'Initialized' if supabase else 'Offline (no DB)'}")
350
 
351
+ # Start background SLA checker as an async task (every 5 minutes)
352
+ if supabase:
353
+ from backend.sla_checker import sla_checker_loop_async
354
+ asyncio.create_task(sla_checker_loop_async(supabase, interval_seconds=300))
355
+ print("[Startup] SLA background checker started (interval=300s)")
356
 
357
+ print("[Startup] Classifier V2 Shadow: Ready.")
358
+ print(f"[Startup] ONNX MiniLM Fallback: {'READY' if getattr(onnx_classifier, '_loaded', False) else 'DEGRADED (artifacts missing)'}")
359
+ print("[Startup] Ready.")
360
+ yield
361
+ print("[Shutdown] Cleaning up ...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
 
363
 
364
  # ---------------------------------------------------------------------------
 
487
  @app.get("/ready", response_model=ReadinessResponse)
488
  async def readiness_check():
489
  require_supabase = os.environ.get("REQUIRE_SUPABASE", "false").lower() == "true"
 
 
490
  checks = {
491
  "api": True,
492
  "classifier_loaded": classifier_service._loaded,
493
  "ner_loaded": ner_service._loaded,
494
+ "duplicate_index_loaded": duplicate_service._loaded,
495
+ "rag_loaded": rag_service._loaded,
496
  }
497
  if require_supabase:
498
  checks["supabase_configured"] = supabase is not None
499
 
500
+ if all(checks.values()):
501
+ return ReadinessResponse(status="ready", checks=checks)
 
 
 
 
 
 
 
 
 
502
 
503
  return JSONResponse(
504
  status_code=503,
 
639
  res = query.execute()
640
  return res.data
641
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
642
  @app.post("/tickets/save")
643
  async def save_ticket(request_body: TicketSaveRequest):
644
  """
 
690
  if not final_data.get("company") and profile.get("company"):
691
  final_data["company"] = profile["company"]
692
 
693
+ user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
694
+ logger.info(f"Tenant linkage: user_hash={user_hash}, company_id={final_data.get('company_id')}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
695
 
 
 
 
 
 
 
 
 
 
696
 
697
+ # Semantic duplicate check BEFORE inserting the ticket
698
+ # This allows us to warn the user before confirming
699
+ duplicate_check_result = None
700
+ try:
701
+ dupe_text = (request_body.description or request_body.subject or "").strip()
702
+ if dupe_text:
703
+ duplicate_check_result = await semantic_dupe_service.check_duplicate(
704
+ text=dupe_text,
705
+ company_id=final_data.get("company_id"),
706
+ )
707
+ if duplicate_check_result["is_duplicate"]:
708
+ logger.info(
709
+ f"[DUPLICATE] Ticket flagged as potential duplicate of "
710
+ f"{duplicate_check_result['duplicate_ticket_id']} "
711
+ f"(similarity: {duplicate_check_result['similarity']})"
712
+ )
713
+ except Exception as e:
714
+ logger.warning(f"[DUPLICATE] Semantic check error (non-fatal): {e}")
715
 
716
  # --- Sanitize payload to only include valid Supabase DB columns ---
717
  # Extra AI telemetry and non-existent schema fields are merged into the metadata JSONB column
 
742
 
743
  ticket_id = res.data[0]["id"]
744
 
745
+ # If duplicate detected, link parent ticket
746
+ if duplicate_check_result and duplicate_check_result["is_duplicate"]:
747
+ try:
748
+ supabase.table("tickets").update({
749
+ "is_potential_duplicate": True,
750
+ "parent_ticket_id": duplicate_check_result["duplicate_ticket_id"],
751
+ }).eq("id", ticket_id).execute()
752
+ except Exception as e:
753
+ logger.warning(f"[DUPLICATE] Failed to link parent ticket: {e}")
754
+
755
+ # Index the new ticket's embedding for future duplicate checks
756
+ embedding_indexed = False
757
+ description_text = (request_body.description or "").strip()
758
+ subject_text = (request_body.subject or "").strip()
759
+ duplicate_text = description_text or subject_text
760
  if duplicate_text:
761
  try:
762
+ # Both: old in-memory index (for backward compat) and new pgvector index
763
  duplicate_service.add_ticket(str(ticket_id), duplicate_text)
764
+ asyncio.create_task(semantic_dupe_service.index_ticket(ticket_id, duplicate_text))
765
+ embedding_indexed = True
766
  except Exception as index_error:
767
+ logger.warning(f"[INDEX] Failed to index ticket {ticket_id}: {index_error}")
 
 
 
 
 
 
768
 
769
  # Add initial system diagnostic message
770
  msg = "Our Neural Engine has successfully triaged your issue and routed it to the designated team."
 
782
  response = {
783
  "status": "success",
784
  "ticket_id": ticket_id,
785
+ "duplicate_indexed": embedding_indexed,
 
 
786
  }
787
+ if duplicate_check_result and duplicate_check_result["is_duplicate"]:
788
+ response["duplicate_warning"] = True
789
+ response["parent_ticket_id"] = duplicate_check_result["duplicate_ticket_id"]
790
+ response["parent_subject"] = duplicate_check_result.get("parent_subject")
791
+ response["similarity"] = duplicate_check_result["similarity"]
792
+ response["candidates"] = duplicate_check_result.get("candidates", [])
793
  return response
794
 
795
  except Exception as e:
 
859
  Main endpoint for analyzing a new ticket using the cascade of local AI models.
860
  """
861
  text = request_body.text
 
 
 
 
 
862
 
863
  # Grab client metadata
864
  client_ip = request.client.host if request.client else "unknown"
 
981
  timeline["metadata_harvested"] = get_now_ist()
982
 
983
  # --- Duplicate detection ---
 
984
  try:
985
+ dup_result = duplicate_service.check_duplicate(text, threshold=request_body.duplicate_sensitivity)
 
 
 
 
986
  except Exception:
987
+ dup_result = {"is_duplicate": False, "duplicate_ticket_id": None, "similarity": 0.0}
 
 
 
 
 
 
988
 
989
  # --- RAG Knowledge Base Check ---
990
  rag_match = None
 
1000
 
1001
  # --- Reasoning ---
1002
  decision_factors = []
1003
+ if classification["confidence"] > request_body.confidence_threshold:
1004
  decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
1005
  if entities:
1006
  decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
 
1010
  decision_factors.append(f"Found solution article: '{rag_match['title']}'")
1011
 
1012
  reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
 
 
 
 
 
 
 
 
1013
  if classification["auto_resolve"]:
1014
  reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
1015
 
 
1019
  if gemini_service and gemini_service._initialized:
1020
  summary = gemini_service.get_summary(text)
1021
 
1022
+ # Convert priority to SLA breached timestamp (for preview)
1023
+ hours_map = {"Critical": 2, "High": 8, "Medium": 24, "Low": 72}
1024
+ sla_hours = hours_map.get(classification["priority"], 72)
1025
+ sla_breach_dt = datetime.datetime.utcnow() + datetime.timedelta(hours=sla_hours)
1026
 
1027
  return TicketResponse(
1028
  ticket_id=str(uuid.uuid4()), # Temporary ID
 
1035
  entities=[EntityInfo(**e) for e in entities],
1036
  duplicate_ticket=DuplicateInfo(**dup_result),
1037
  confidence=classification["confidence"],
1038
+ needs_review=classification["confidence"] < 0.20,
1039
  reasoning=reasoning,
1040
  decision_factors=decision_factors,
1041
  image_description=gemini_analysis["image_description"],
 
1043
  highlights=[e.get("text", "") for e in entities], # Use entity texts as highlights for now
1044
  timeline=timeline,
1045
  env_metadata=env_metadata,
1046
+ sla_breach_at=sla_breach_dt.isoformat() + "Z"
 
 
1047
  )
1048
 
1049
  @app.post("/ai/analyze_stream")
 
1062
  "model_version": "3.0.0-PRO",
1063
  "api_endpoint": "/ai/analyze_stream"
1064
  }
1065
+ timeline = {"received": get_now_ist()}
 
 
 
 
1066
 
1067
  # 1. Reading
1068
  yield f"data: {json.dumps({'step': 'Reading your message', 'status': 'in_progress'})}\n\n"
 
1098
  yield f"data: {json.dumps({'step': 'Checking duplicate issues', 'status': 'in_progress'})}\n\n"
1099
  await asyncio.sleep(0.2)
1100
  try:
1101
+ dup_result = duplicate_service.check_duplicate(text, threshold=request_body.duplicate_sensitivity)
 
 
 
 
 
1102
  except Exception:
1103
+ dup_result = {"is_duplicate": False, "duplicate_ticket_id": None, "similarity": 0.0}
 
 
 
 
 
 
1104
 
1105
  # 5. RAG / Solutions
1106
  yield f"data: {json.dumps({'step': 'Finding possible solutions', 'status': 'in_progress'})}\n\n"
 
1116
  pass
1117
 
1118
  decision_factors = []
1119
+ if classification["confidence"] > request_body.confidence_threshold:
1120
  decision_factors.append(f"High confidence match for '{classification['subcategory']}'")
1121
  if entities:
1122
  decision_factors.append(f"Detected entities: {', '.join([e['text'] for e in entities[:2]])}")
 
1125
  if rag_match:
1126
  decision_factors.append(f"Found solution article: '{rag_match['title']}'")
1127
 
 
 
1128
  reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
1129
  if classification["auto_resolve"]:
1130
  reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
 
1134
  if gemini_service and gemini_service._initialized:
1135
  summary = gemini_service.get_summary(text)
1136
 
1137
+ hours_map = {"Critical": 2, "High": 8, "Medium": 24, "Low": 72}
1138
+ sla_hours = hours_map.get(classification["priority"], 72)
1139
+ sla_breach_dt = datetime.datetime.utcnow() + datetime.timedelta(hours=sla_hours)
1140
 
1141
  ticket_response_dict = {
1142
  "ticket_id": str(uuid.uuid4()),
 
1149
  "entities": [e for e in entities],
1150
  "duplicate_ticket": dup_result,
1151
  "confidence": classification["confidence"],
1152
+ "needs_review": classification["confidence"] < 0.20,
1153
  "reasoning": reasoning,
1154
  "decision_factors": decision_factors,
1155
  "image_description": gemini_analysis["image_description"],
 
1157
  "highlights": [e.get("text", "") for e in entities],
1158
  "timeline": timeline,
1159
  "env_metadata": env_metadata,
1160
+ "sla_breach_at": sla_breach_dt.isoformat() + "Z"
 
 
1161
  }
1162
 
1163
  # 6. Final Result
 
1165
 
1166
  return StreamingResponse(event_generator(), media_type="text/event-stream")
1167
 
1168
+ @app.post("/ai/analyze_ticket")
1169
  async def legacy_analyze_and_save(request_body: TicketRequest):
1170
  """
1171
  BACKWARD COMPATIBILITY: Strictly performs analysis only.
 
1189
  }
1190
  except Exception as e:
1191
  raise HTTPException(status_code=500, detail=str(e))
1192
+
1193
+
1194
+ # ---------------------------------------------------------------------------
1195
+ # SLA Engine Endpoints
1196
+ # ---------------------------------------------------------------------------
1197
+
1198
+ class SLAStatsResponse(BaseModel):
1199
+ total: int = 0
1200
+ active: int = 0
1201
+ breached: int = 0
1202
+ warning: int = 0
1203
+ met: int = 0
1204
+ breach_rate: float = 0.0
1205
+ by_priority: dict = {}
1206
+
1207
+
1208
+ @app.get("/sla/stats", response_model=SLAStatsResponse)
1209
+ async def sla_stats():
1210
+ """Get aggregated SLA dashboard statistics across all tickets."""
1211
+ if not supabase:
1212
+ raise HTTPException(status_code=503, detail="Database not connected")
1213
+ stats = await sla_engine.get_dashboard_stats()
1214
+ if "error" in stats:
1215
+ raise HTTPException(status_code=500, detail=stats["error"])
1216
+ return stats
1217
+
1218
+
1219
+ class SLATicketInfo(BaseModel):
1220
+ id: str
1221
+ ticket_id: str | None = None
1222
+ subject: str | None = None
1223
+ summary: str | None = None
1224
+ priority: str = "medium"
1225
+ status: str | None = None
1226
+ assigned_team: str | None = None
1227
+ sla_status: str = "active"
1228
+ escalation_level: int = 0
1229
+ remaining_seconds: int = 0
1230
+ created_at: str | None = None
1231
+ sla_breach_at: str | None = None
1232
+ sla_warning_at: str | None = None
1233
+ last_escalated_at: str | None = None
1234
+
1235
+
1236
+ @app.get("/sla/tickets")
1237
+ async def sla_tickets(
1238
+ status: str | None = None,
1239
+ priority: str | None = None,
1240
+ limit: int = 100,
1241
+ offset: int = 0,
1242
+ ):
1243
+ """
1244
+ List tickets with SLA status. Filter by sla_status and/or priority.
1245
+ """
1246
+ if not supabase:
1247
+ raise HTTPException(status_code=503, detail="Database not connected")
1248
+
1249
+ query = (
1250
+ supabase.table("tickets")
1251
+ .select("id, ticket_id, subject, summary, priority, status, assigned_team, sla_status, escalation_level, remaining_seconds, created_at, sla_breach_at, sla_warning_at, last_escalated_at")
1252
+ .order("created_at", desc=True)
1253
+ )
1254
+
1255
+ if status and status != "all":
1256
+ query = query.eq("sla_status", status)
1257
+ if priority and priority != "all":
1258
+ query = query.eq("priority", priority.capitalize())
1259
+
1260
+ query = query.range(offset, offset + limit - 1)
1261
+ res = query.execute()
1262
+ return {"tickets": res.data or [], "total": len(res.data or [])}
1263
+
1264
+
1265
+ class EscalationLogEntry(BaseModel):
1266
+ id: str
1267
+ ticket_id: str | None = None
1268
+ ticket_subject: str = ""
1269
+ priority: str = "medium"
1270
+ sla_status: str = ""
1271
+ escalation_level: int = 0
1272
+ remaining_seconds: int = 0
1273
+ assigned_team: str = ""
1274
+ notification_channels: list = []
1275
+ triggered_at: str | None = None
1276
+ resolved_at: str | None = None
1277
+ notes: str = ""
1278
+
1279
+
1280
+ @app.get("/sla/escalations")
1281
+ async def sla_escalations(limit: int = 50, offset: int = 0):
1282
+ """Fetch escalation log history."""
1283
+ if not supabase:
1284
+ raise HTTPException(status_code=503, detail="Database not connected")
1285
+
1286
+ try:
1287
+ res = (
1288
+ supabase.table("escalation_logs")
1289
+ .select("*")
1290
+ .order("triggered_at", desc=True)
1291
+ .range(offset, offset + limit - 1)
1292
+ .execute()
1293
+ )
1294
+ return {"escalations": res.data or [], "total": len(res.data or [])}
1295
+ except Exception as e:
1296
+ # Table might not exist yet
1297
+ print(f"[SLA] Escalation logs query failed: {e}")
1298
+ return {"escalations": [], "total": 0}
1299
+
1300
+
1301
+ class SLAPolicyInfo(BaseModel):
1302
+ id: str
1303
+ priority: str
1304
+ max_hours: int
1305
+ warning_pct: float
1306
+ auto_escalate: bool
1307
+ l2_after_minutes: int
1308
+ l3_after_minutes: int
1309
+
1310
+
1311
+ @app.get("/sla/policies")
1312
+ async def sla_policies():
1313
+ """Get configured SLA policies."""
1314
+ if not supabase:
1315
+ # Return defaults from code
1316
+ policies = []
1317
+ for pri, cfg in sla_engine.SLA_POLICIES.items() if hasattr(sla_engine, 'SLA_POLICIES') else SLA_POLICIES.items():
1318
+ policies.append({
1319
+ "priority": pri,
1320
+ "max_hours": cfg["max_hours"],
1321
+ "warning_pct": cfg["warning_pct"],
1322
+ "auto_escalate": cfg["auto_escalate_on_breach"],
1323
+ "l2_after_minutes": cfg["l2_escalation_mins"],
1324
+ "l3_after_minutes": cfg["l3_escalation_mins"],
1325
+ })
1326
+ return {"policies": policies}
1327
+
1328
+ try:
1329
+ res = supabase.table("sla_policies").select("*").execute()
1330
+ return {"policies": res.data or []}
1331
+ except Exception as e:
1332
+ print(f"[SLA] Policies query failed: {e}")
1333
+ return {"policies": []}
1334
+
1335
+
1336
+ @app.post("/sla/check")
1337
+ async def trigger_sla_check():
1338
+ """Manually trigger an SLA evaluation cycle (admin)."""
1339
+ if not supabase:
1340
+ raise HTTPException(status_code=503, detail="Database not connected")
1341
+
1342
+ asyncio.create_task(sla_engine.check_all_active_tickets())
1343
+ return {"status": "triggered", "message": "SLA check cycle started in background"}
1344
+
1345
+
1346
+ # ---------------------------------------------------------------------------
1347
+ # Semantic Duplicate Detection Endpoints
1348
+ # ---------------------------------------------------------------------------
1349
+
1350
+ @app.post("/ai/check_duplicate")
1351
+ async def check_duplicate_endpoint(
1352
+ body: TicketRequest,
1353
+ company_id: str | None = None,
1354
+ ):
1355
+ """
1356
+ Check a ticket text for potential duplicates using semantic vector search.
1357
+ Returns top candidates with similarity scores.
1358
+ """
1359
+ text = (body.text or "").strip()
1360
+ if not text:
1361
+ raise HTTPException(status_code=400, detail="No text provided")
1362
+
1363
+ threshold = body.duplicate_sensitivity if hasattr(body, 'duplicate_sensitivity') else None
1364
+ result = await semantic_dupe_service.check_duplicate(
1365
+ text=text,
1366
+ company_id=company_id or body.company,
1367
+ threshold=threshold,
1368
+ )
1369
+ return result
1370
+
1371
+
1372
+ @app.post("/ai/reindex_embeddings")
1373
+ async def reindex_embeddings():
1374
+ """Re-generate vector embeddings for all tickets."""
1375
+ result = await semantic_dupe_service.reindex_all()
1376
+ return result
1377
+
1378
+
1379
+ @app.get("/system/settings")
1380
+ async def get_system_settings():
1381
+ """Fetch all system settings."""
1382
+ if not supabase:
1383
+ raise HTTPException(status_code=503, detail="Database not connected")
1384
+ try:
1385
+ res = supabase.table("system_settings").select("*").execute()
1386
+ settings = {}
1387
+ for row in res.data or []:
1388
+ settings[row["key"]] = row["value"]
1389
+ return settings
1390
+ except Exception as e:
1391
+ logger.warning(f"[SETTINGS] Query failed: {e}")
1392
+ return {}
1393
+
1394
+
1395
+ @app.patch("/system/settings")
1396
+ async def update_system_settings(body: dict):
1397
+ """Update a specific system setting."""
1398
+ if not supabase:
1399
+ raise HTTPException(status_code=503, detail="Database not connected")
1400
+ key = body.get("key")
1401
+ value = body.get("value")
1402
+ if not key or value is None:
1403
+ raise HTTPException(status_code=400, detail="key and value required")
1404
+ try:
1405
+ supabase.table("system_settings").upsert({
1406
+ "key": key,
1407
+ "value": value,
1408
+ "updated_at": datetime.datetime.utcnow().isoformat() + "Z",
1409
+ }).execute()
1410
+ return {"status": "updated", "key": key}
1411
+ except Exception as e:
1412
+ raise HTTPException(status_code=500, detail=str(e))
1413
+
1414
+
1415
+ @app.get("/sla/tickets/{ticket_id}")
1416
+ async def sla_ticket_detail(ticket_id: str):
1417
+ """Get detailed SLA info for a specific ticket."""
1418
+ if not supabase:
1419
+ raise HTTPException(status_code=503, detail="Database not connected")
1420
+
1421
+ # Fetch ticket
1422
+ res = supabase.table("tickets").select("*").eq("id", ticket_id).single().execute()
1423
+ if not res.data:
1424
+ raise HTTPException(status_code=404, detail="Ticket not found")
1425
+
1426
+ ticket = res.data
1427
+ result = sla_engine.evaluate_ticket(ticket)
1428
+
1429
+ # Fetch escalation history for this ticket
1430
+ try:
1431
+ esc_res = (
1432
+ supabase.table("escalation_logs")
1433
+ .select("*")
1434
+ .eq("ticket_id", ticket_id)
1435
+ .order("triggered_at", desc=True)
1436
+ .execute()
1437
+ )
1438
+ escalations = esc_res.data or []
1439
+ except Exception:
1440
+ escalations = []
1441
+
1442
+ return {
1443
+ "ticket": ticket,
1444
+ "sla_evaluation": result,
1445
+ "escalations": escalations,
1446
+ }
backend/services/semantic_duplicate_service.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Semantic Duplicate Detection Service — pgvector-powered cosine similarity search.
3
+
4
+ Enhances the existing in-memory DuplicateService with Supabase pgvector integration:
5
+ 1. Store embeddings in the tickets table (description_vector column)
6
+ 2. Use match_tickets RPC for company-scoped similarity search
7
+ 3. Dynamic sensitivity from system_settings table
8
+ 4. Auto-flag duplicates during ticket save flow
9
+
10
+ Usage:
11
+ service = SemanticDuplicateService(supabase_client)
12
+ result = await service.check_duplicate(text, company_id, threshold_override)
13
+ await service.index_ticket(ticket_id, text)
14
+ """
15
+
16
+ import os
17
+ import json
18
+ import logging
19
+ from typing import Any
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Default threshold (0.0-1.0, higher = stricter)
24
+ DEFAULT_SENSITIVITY = 0.85
25
+
26
+
27
+ class SemanticDuplicateService:
28
+ """
29
+ Duplicate detection backed by pgvector in Supabase.
30
+
31
+ Falls back to in-memory DuplicateService if the vector DB is unavailable,
32
+ but the primary path is via the match_tickets RPC function.
33
+ """
34
+
35
+ def __init__(self, supabase_client=None):
36
+ self.supabase = supabase_client
37
+ self._model = None
38
+ self._loaded = False
39
+
40
+ def load(self):
41
+ """Lazy-load the sentence-transformers model (called once)."""
42
+ if self._loaded:
43
+ return
44
+ try:
45
+ from sentence_transformers import SentenceTransformer
46
+ self._model = SentenceTransformer("all-MiniLM-L6-v2")
47
+ self._loaded = True
48
+ logger.info("[SemanticDuplicate] Model loaded (all-MiniLM-L6-v2)")
49
+ except ImportError:
50
+ logger.warning("[SemanticDuplicate] sentence-transformers not installed")
51
+ except Exception as e:
52
+ logger.error(f"[SemanticDuplicate] Model load error: {e}")
53
+
54
+ @property
55
+ def model(self):
56
+ if not self._loaded:
57
+ self.load()
58
+ return self._model
59
+
60
+ # ------------------------------------------------------------------
61
+ # Embedding generation
62
+ # ------------------------------------------------------------------
63
+
64
+ def generate_embedding(self, text: str) -> list[float] | None:
65
+ """
66
+ Generate a 384-dimensional embedding vector for the given text.
67
+ Returns None if the model isn't loaded.
68
+ """
69
+ if not self.model:
70
+ return None
71
+ try:
72
+ return self.model.encode(text).tolist()
73
+ except Exception as e:
74
+ logger.error(f"[SemanticDuplicate] Embedding error: {e}")
75
+ return None
76
+
77
+ # ------------------------------------------------------------------
78
+ # Dynamic threshold from system_settings
79
+ # ------------------------------------------------------------------
80
+
81
+ async def get_sensitivity(self) -> float:
82
+ """
83
+ Fetch the duplicate detection sensitivity from system_settings.
84
+ Falls back to DEFAULT_SENSITIVITY.
85
+ """
86
+ if not self.supabase:
87
+ return DEFAULT_SENSITIVITY
88
+ try:
89
+ res = self.supabase.table("system_settings") \
90
+ .select("value") \
91
+ .eq("key", "duplicate_detection") \
92
+ .single() \
93
+ .execute()
94
+ if res.data:
95
+ return float(res.data["value"].get("sensitivity", DEFAULT_SENSITIVITY))
96
+ except Exception as e:
97
+ logger.warning(f"[SemanticDuplicate] Failed to fetch sensitivity: {e}")
98
+ return DEFAULT_SENSITIVITY
99
+
100
+ # ------------------------------------------------------------------
101
+ # Duplicate check via pgvector RPC
102
+ # ------------------------------------------------------------------
103
+
104
+ async def check_duplicate(
105
+ self,
106
+ text: str,
107
+ company_id: str | None = None,
108
+ threshold: float | None = None,
109
+ max_candidates: int = 5,
110
+ ) -> dict:
111
+ """
112
+ Check text against existing tickets using pgvector cosine similarity.
113
+
114
+ Args:
115
+ text: The ticket description/subject text.
116
+ company_id: Tenant company UUID for scoped search.
117
+ threshold: Override threshold (0.0-1.0). Uses dynamic setting if None.
118
+ max_candidates: Max similar tickets to return.
119
+
120
+ Returns:
121
+ {
122
+ "is_duplicate": bool,
123
+ "duplicate_ticket_id": str | None,
124
+ "similarity": float,
125
+ "candidates": [...] # top candidates with scores
126
+ }
127
+ """
128
+ # Generate embedding
129
+ embedding = self.generate_embedding(text)
130
+ if embedding is None:
131
+ logger.warning("[SemanticDuplicate] No embedding — falling back to no match")
132
+ return {
133
+ "is_duplicate": False,
134
+ "duplicate_ticket_id": None,
135
+ "similarity": 0.0,
136
+ "candidates": [],
137
+ }
138
+
139
+ active_threshold = threshold if threshold is not None else await self.get_sensitivity()
140
+
141
+ # Perform vector search
142
+ if self.supabase:
143
+ try:
144
+ result = self.supabase.rpc(
145
+ "match_tickets",
146
+ {
147
+ "query_vector": embedding,
148
+ "match_threshold": active_threshold,
149
+ "match_count": max_candidates,
150
+ "tenant_company_id": company_id,
151
+ },
152
+ ).execute()
153
+
154
+ candidates = result.data or []
155
+ except Exception as e:
156
+ logger.warning(f"[SemanticDuplicate] RPC call failed: {e}")
157
+ candidates = []
158
+ else:
159
+ candidates = []
160
+
161
+ if not candidates:
162
+ return {
163
+ "is_duplicate": False,
164
+ "duplicate_ticket_id": None,
165
+ "similarity": 0.0,
166
+ "candidates": [],
167
+ }
168
+
169
+ best = candidates[0]
170
+ similarity = float(best.get("similarity", 0.0))
171
+ is_dup = similarity >= active_threshold
172
+
173
+ return {
174
+ "is_duplicate": is_dup,
175
+ "duplicate_ticket_id": best.get("id") if is_dup else None,
176
+ "similarity": round(similarity, 4),
177
+ "parent_subject": best.get("subject") or best.get("summary") if is_dup else None,
178
+ "candidates": [
179
+ {
180
+ "id": c["id"],
181
+ "subject": c.get("subject") or c.get("summary"),
182
+ "similarity": round(float(c.get("similarity", 0)), 4),
183
+ "status": c.get("status"),
184
+ "assigned_team": c.get("assigned_team"),
185
+ "created_at": c.get("created_at"),
186
+ }
187
+ for c in candidates[:3]
188
+ ],
189
+ }
190
+
191
+ # ------------------------------------------------------------------
192
+ # Index a ticket — generate embedding and store it
193
+ # ------------------------------------------------------------------
194
+
195
+ async def index_ticket(self, ticket_id: str, text: str) -> bool:
196
+ """
197
+ Generate embedding for a ticket and update the description_vector column.
198
+ Returns True on success.
199
+ """
200
+ embedding = self.generate_embedding(text)
201
+ if embedding is None:
202
+ logger.warning(f"[SemanticDuplicate] Cannot index ticket {ticket_id}: no embedding")
203
+ return False
204
+
205
+ if not self.supabase:
206
+ logger.warning("[SemanticDuplicate] No Supabase — skipping index")
207
+ return False
208
+
209
+ try:
210
+ self.supabase.table("tickets") \
211
+ .update({"description_vector": embedding}) \
212
+ .eq("id", ticket_id) \
213
+ .execute()
214
+ logger.info(f"[SemanticDuplicate] Indexed ticket {ticket_id} ({len(text)} chars)")
215
+ return True
216
+ except Exception as e:
217
+ logger.error(f"[SemanticDuplicate] Index error for {ticket_id}: {e}")
218
+ return False
219
+
220
+ # ------------------------------------------------------------------
221
+ # Batch re-index — regenerate all embeddings
222
+ # ------------------------------------------------------------------
223
+
224
+ async def reindex_all(self, batch_size: int = 50) -> dict:
225
+ """
226
+ Re-generate embeddings for all tickets that don't have them.
227
+ Useful for initial migration or after model update.
228
+ """
229
+ if not self.supabase:
230
+ return {"indexed": 0, "errors": 0}
231
+
232
+ total_indexed = 0
233
+ total_errors = 0
234
+ offset = 0
235
+
236
+ while True:
237
+ res = self.supabase.table("tickets") \
238
+ .select("id, subject, description, summary") \
239
+ .is_("description_vector", "null") \
240
+ .range(offset, offset + batch_size - 1) \
241
+ .execute()
242
+
243
+ batch = res.data or []
244
+ if not batch:
245
+ break
246
+
247
+ for ticket in batch:
248
+ text = (ticket.get("description") or ticket.get("subject") or ticket.get("summary") or "").strip()
249
+ if not text:
250
+ continue
251
+ success = await self.index_ticket(ticket["id"], text)
252
+ if success:
253
+ total_indexed += 1
254
+ else:
255
+ total_errors += 1
256
+
257
+ offset += batch_size
258
+
259
+ logger.info(f"[SemanticDuplicate] Re-index complete: {total_indexed} indexed, {total_errors} errors")
260
+ return {"indexed": total_indexed, "errors": total_errors}
backend/services/sla_engine.py ADDED
@@ -0,0 +1,534 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SLA Engine — Service-Level Agreement monitoring, breach detection,
3
+ and multi-channel escalation for HELPDESK.AI.
4
+
5
+ Architecture:
6
+ - SLA policies defined per priority tier
7
+ - Background checker evaluates ticket SLA in batches
8
+ - Escalation matrix drives notification routing
9
+ - Multi-channel dispatch (Email / Slack / Teams / Webhook)
10
+ """
11
+
12
+ import os
13
+ import json
14
+ import logging
15
+ import datetime
16
+ from typing import Any
17
+ from enum import Enum
18
+ from pydantic import BaseModel
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Constants
24
+ # ---------------------------------------------------------------------------
25
+
26
+ class SLAStatus(str, Enum):
27
+ ACTIVE = "active"
28
+ WARNING = "warning"
29
+ BREACHED = "breached"
30
+ MET = "met"
31
+ PAUSED = "paused" # ticket on hold
32
+
33
+ class EscalationLevel(int, Enum):
34
+ NONE = 0
35
+ LEVEL_1 = 1 # Warning → notify assigned team
36
+ LEVEL_2 = 2 # Breach → escalate to L2 / manager
37
+ LEVEL_3 = 3 # Extended breach → escalate to L3 / senior / admin
38
+
39
+ class ChannelType(str, Enum):
40
+ EMAIL = "email"
41
+ SLACK = "slack"
42
+ TEAMS = "teams"
43
+ WEBHOOK = "webhook"
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # SLA Policy definitions
47
+ # ---------------------------------------------------------------------------
48
+
49
+ SLAMetric = dict[str, Any] # {"max_hours": N, "warning_pct": 0.75, "l2_escalation_after_mins": M, ...}
50
+
51
+ SLA_POLICIES: dict[str, SLAMetric] = {
52
+ "critical": {
53
+ "max_hours": 2,
54
+ "max_seconds": 2 * 3600,
55
+ "warning_pct": 0.75, # warn at 75% of SLA
56
+ "warning_label": "1.5h remaining",
57
+ "l2_escalation_mins": 0, # escalate to L2 immediately on breach
58
+ "l3_escalation_mins": 120, # escalate to L3 after 2h of breach
59
+ "auto_escalate_on_breach": True,
60
+ },
61
+ "high": {
62
+ "max_hours": 4,
63
+ "max_seconds": 4 * 3600,
64
+ "warning_pct": 0.75,
65
+ "warning_label": "3h remaining",
66
+ "l2_escalation_mins": 30,
67
+ "l3_escalation_mins": 240,
68
+ "auto_escalate_on_breach": True,
69
+ },
70
+ "medium": {
71
+ "max_hours": 8,
72
+ "max_seconds": 8 * 3600,
73
+ "warning_pct": 0.75,
74
+ "warning_label": "6h remaining",
75
+ "l2_escalation_mins": 60,
76
+ "l3_escalation_mins": 480,
77
+ "auto_escalate_on_breach": True,
78
+ },
79
+ "low": {
80
+ "max_hours": 24,
81
+ "max_seconds": 24 * 3600,
82
+ "warning_pct": 0.75,
83
+ "warning_label": "18h remaining",
84
+ "l2_escalation_mins": 120,
85
+ "l3_escalation_mins": 1440,
86
+ "auto_escalate_on_breach": False, # low priority: no auto-escalation
87
+ },
88
+ }
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Escalation contact mapping (configured via env / DB)
92
+ # ---------------------------------------------------------------------------
93
+
94
+ def _load_escalation_channels() -> list[dict]:
95
+ """Load channel config from JSON file or env vars."""
96
+ channels_raw = os.environ.get("SLA_CHANNELS", "[]")
97
+ try:
98
+ return json.loads(channels_raw)
99
+ except json.JSONDecodeError:
100
+ return []
101
+
102
+ def _load_team_escalation_contacts() -> dict:
103
+ """Load team escalation contacts."""
104
+ contacts_raw = os.environ.get("SLA_ESCALATION_CONTACTS", "{}")
105
+ try:
106
+ return json.loads(contacts_raw)
107
+ except json.JSONDecodeError:
108
+ return {}
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Core SLA Engine
112
+ # ---------------------------------------------------------------------------
113
+
114
+ class SLAEngine:
115
+ """
116
+ Evaluates ticket SLA status, detects breaches, and dispatches escalations.
117
+
118
+ Usage:
119
+ engine = SLAEngine(supabase_client)
120
+ await engine.check_all_active_tickets()
121
+ """
122
+
123
+ def __init__(self, supabase_client=None):
124
+ self.supabase = supabase_client
125
+ self.channels = _load_escalation_channels()
126
+ self.contacts = _load_team_escalation_contacts()
127
+
128
+ # ------------------------------------------------------------------
129
+ # SLA Evaluation per ticket
130
+ # ------------------------------------------------------------------
131
+
132
+ def evaluate_ticket(self, ticket: dict) -> dict:
133
+ """
134
+ Evaluate a single ticket's SLA status.
135
+
136
+ Returns dict with:
137
+ - sla_status: SLAStatus value
138
+ - remaining_seconds: seconds until breach (negative if breached)
139
+ - elapsed_pct: percentage of SLA time elapsed
140
+ - escalation_level: EscalationLevel value
141
+ - needs_notification: bool
142
+ - policy: the applied SLA policy
143
+ """
144
+ priority_key = (ticket.get("priority") or "medium").lower().strip()
145
+ policy = SLA_POLICIES.get(priority_key, SLA_POLICIES["medium"])
146
+
147
+ # Resolve the start time: use sla_started_at if set, else created_at
148
+ start_str = ticket.get("sla_started_at") or ticket.get("created_at")
149
+ if not start_str:
150
+ return self._default_eval(policy)
151
+
152
+ try:
153
+ created_dt = datetime.datetime.fromisoformat(start_str.replace("Z", "+00:00"))
154
+ except (ValueError, TypeError):
155
+ return self._default_eval(policy)
156
+
157
+ # If ticket is resolved / closed, SLA is "met"
158
+ status = (ticket.get("status") or "").lower()
159
+ if any(s in status for s in ["resolv", "closed", "auto-resolv"]):
160
+ return {
161
+ "sla_status": SLAStatus.MET,
162
+ "remaining_seconds": 0,
163
+ "elapsed_pct": 1.0,
164
+ "escalation_level": EscalationLevel.NONE,
165
+ "needs_notification": False,
166
+ "policy": policy,
167
+ }
168
+
169
+ # Calculate elapsed time
170
+ now = datetime.datetime.now(datetime.timezone.utc)
171
+ elapsed = (now - created_dt).total_seconds()
172
+ max_sec = policy["max_seconds"]
173
+ elapsed_pct = elapsed / max_sec if max_sec > 0 else 0
174
+
175
+ # Determine SLA status
176
+ if elapsed >= max_sec:
177
+ sla_status = SLAStatus.BREACHED
178
+ remaining_seconds = -(elapsed - max_sec)
179
+ elif elapsed >= max_sec * policy["warning_pct"]:
180
+ sla_status = SLAStatus.WARNING
181
+ remaining_seconds = max_sec - elapsed
182
+ else:
183
+ sla_status = SLAStatus.ACTIVE
184
+ remaining_seconds = max_sec - elapsed
185
+
186
+ # Determine escalation level
187
+ escalation_level = EscalationLevel.NONE
188
+ if sla_status == SLAStatus.BREACHED:
189
+ breach_duration = elapsed - max_sec
190
+ if breach_duration >= policy["l3_escalation_mins"] * 60:
191
+ escalation_level = EscalationLevel.LEVEL_3
192
+ elif breach_duration >= policy["l2_escalation_mins"] * 60:
193
+ escalation_level = EscalationLevel.LEVEL_2
194
+ else:
195
+ escalation_level = EscalationLevel.LEVEL_2 # breach = at least L2
196
+ elif sla_status == SLAStatus.WARNING:
197
+ escalation_level = EscalationLevel.LEVEL_1
198
+
199
+ needs_notification = (
200
+ sla_status in (SLAStatus.WARNING, SLAStatus.BREACHED)
201
+ and escalation_level.value > (ticket.get("escalation_level") or 0)
202
+ )
203
+
204
+ return {
205
+ "sla_status": sla_status.value,
206
+ "remaining_seconds": round(remaining_seconds),
207
+ "elapsed_pct": round(elapsed_pct, 4),
208
+ "escalation_level": escalation_level.value,
209
+ "needs_notification": needs_notification,
210
+ "policy": policy,
211
+ }
212
+
213
+ def _default_eval(self, policy: dict) -> dict:
214
+ return {
215
+ "sla_status": SLAStatus.ACTIVE.value,
216
+ "remaining_seconds": policy["max_seconds"],
217
+ "elapsed_pct": 0.0,
218
+ "escalation_level": 0,
219
+ "needs_notification": False,
220
+ "policy": policy,
221
+ }
222
+
223
+ # ------------------------------------------------------------------
224
+ # Batch check — evaluate all active tickets
225
+ # ------------------------------------------------------------------
226
+
227
+ async def check_all_active_tickets(self) -> list[dict]:
228
+ """
229
+ Fetches all active (non-resolved) tickets from Supabase,
230
+ evaluates SLA, updates DB records, and triggers notifications.
231
+
232
+ Returns list of escalated tickets that need notification dispatch.
233
+ """
234
+ if not self.supabase:
235
+ logger.warning("[SLA] No Supabase client — skipping SLA check.")
236
+ return []
237
+
238
+ try:
239
+ res = self.supabase.table("tickets") \
240
+ .select("*") \
241
+ .not_.ilike("status", "%resolv%") \
242
+ .not_.ilike("status", "%closed%") \
243
+ .execute()
244
+ except Exception as e:
245
+ logger.error(f"[SLA] Failed to fetch tickets: {e}")
246
+ return []
247
+
248
+ tickets = res.data or []
249
+ escalated: list[dict] = []
250
+
251
+ for ticket in tickets:
252
+ result = self.evaluate_ticket(ticket)
253
+ update_fields = {
254
+ "sla_status": result["sla_status"],
255
+ "remaining_seconds": result["remaining_seconds"],
256
+ "escalation_level": result["escalation_level"],
257
+ "sla_updated_at": datetime.datetime.utcnow().isoformat() + "Z",
258
+ }
259
+
260
+ # If breached and not yet marked as breached, set breached timestamp
261
+ if result["sla_status"] == SLAStatus.BREACHED.value and not ticket.get("sla_breach_at"):
262
+ update_fields["sla_breach_at"] = datetime.datetime.utcnow().isoformat() + "Z"
263
+
264
+ # If warning and not yet notified, mark warning time
265
+ if result["sla_status"] == SLAStatus.WARNING.value and not ticket.get("sla_warning_at"):
266
+ update_fields["sla_warning_at"] = datetime.datetime.utcnow().isoformat() + "Z"
267
+
268
+ # If needs notification, prepare escalation record
269
+ if result["needs_notification"]:
270
+ update_fields["last_escalated_at"] = datetime.datetime.utcnow().isoformat() + "Z"
271
+ escalated.append({
272
+ "ticket": ticket,
273
+ "sla_result": result,
274
+ })
275
+
276
+ # Update ticket in DB
277
+ try:
278
+ self.supabase.table("tickets") \
279
+ .update(update_fields) \
280
+ .eq("id", ticket["id"]) \
281
+ .execute()
282
+ except Exception as e:
283
+ logger.error(f"[SLA] Failed to update ticket {ticket.get('id')}: {e}")
284
+
285
+ # Log escalations to escalation_logs table
286
+ for item in escalated:
287
+ self._log_escalation(item["ticket"], item["sla_result"])
288
+
289
+ logger.info(
290
+ f"[SLA] Checked {len(tickets)} tickets "
291
+ f"— {len(escalated)} escalated."
292
+ )
293
+ return escalated
294
+
295
+ # ------------------------------------------------------------------
296
+ # Escalation logging
297
+ # ------------------------------------------------------------------
298
+
299
+ def _log_escalation(self, ticket: dict, result: dict):
300
+ """Insert an escalation event into the escalation_logs table."""
301
+ if not self.supabase:
302
+ return
303
+ try:
304
+ self.supabase.table("escalation_logs").insert({
305
+ "ticket_id": ticket["id"],
306
+ "ticket_subject": ticket.get("subject") or ticket.get("summary", ""),
307
+ "priority": ticket.get("priority", "medium"),
308
+ "sla_status": result["sla_status"],
309
+ "escalation_level": result["escalation_level"],
310
+ "remaining_seconds": result["remaining_seconds"],
311
+ "assigned_team": ticket.get("assigned_team", ""),
312
+ "triggered_at": datetime.datetime.utcnow().isoformat() + "Z",
313
+ "notification_channels": [], # populated by notifier
314
+ }).execute()
315
+ except Exception as e:
316
+ logger.error(f"[SLA] Failed to log escalation: {e}")
317
+
318
+ # ------------------------------------------------------------------
319
+ # Multi-channel notification dispatch
320
+ # ------------------------------------------------------------------
321
+
322
+ async def dispatch_notifications(self, escalated_items: list[dict]):
323
+ """
324
+ Dispatch SLA alerts across configured channels.
325
+ Each item: { ticket, sla_result }
326
+ """
327
+ for item in escalated_items:
328
+ ticket = item["ticket"]
329
+ result = item["sla_result"]
330
+ channels = self._resolve_channels(ticket, result)
331
+
332
+ for channel in channels:
333
+ success = await self._send_to_channel(channel, ticket, result)
334
+ if success:
335
+ logger.info(
336
+ f"[SLA] Notification sent via {channel['type']} "
337
+ f"for ticket {ticket.get('id')}"
338
+ )
339
+
340
+ def _resolve_channels(self, ticket: dict, result: dict) -> list[dict]:
341
+ """Determine which channels to send to based on escalation level."""
342
+ level = result["escalation_level"]
343
+ active_channels = [c for c in self.channels if c.get("enabled", True)]
344
+
345
+ # Filter by minimum escalation level
346
+ eligible = [
347
+ c for c in active_channels
348
+ if c.get("min_level", 0) <= level
349
+ ]
350
+
351
+ return eligible
352
+
353
+ async def _send_to_channel(self, channel: dict, ticket: dict, result: dict) -> bool:
354
+ """Send an SLA notification through a specific channel."""
355
+ channel_type = channel.get("type", "webhook")
356
+ payload = self._build_payload(ticket, result, channel_type)
357
+
358
+ try:
359
+ import aiohttp
360
+ async with aiohttp.ClientSession() as session:
361
+ async with session.post(
362
+ channel["url"],
363
+ json=payload,
364
+ headers=channel.get("headers", {"Content-Type": "application/json"}),
365
+ timeout=aiohttp.ClientTimeout(total=10),
366
+ ) as resp:
367
+ return resp.status < 500
368
+ except ImportError:
369
+ logger.warning("[SLA] aiohttp not installed — using requests (sync).")
370
+ import requests
371
+ try:
372
+ resp = requests.post(
373
+ channel["url"],
374
+ json=payload,
375
+ headers=channel.get("headers", {"Content-Type": "application/json"}),
376
+ timeout=10,
377
+ )
378
+ return resp.status_code < 500
379
+ except Exception as e:
380
+ logger.error(f"[SLA] Channel send failed ({channel_type}): {e}")
381
+ return False
382
+ except Exception as e:
383
+ logger.error(f"[SLA] Channel send failed ({channel_type}): {e}")
384
+ return False
385
+
386
+ def _build_payload(self, ticket: dict, result: dict, channel_type: str) -> dict:
387
+ """Build a channel-specific notification payload."""
388
+ priority = (ticket.get("priority") or "medium").upper()
389
+ ticket_id = str(ticket.get("id", "???"))[:8].upper()
390
+ subject = ticket.get("subject") or ticket.get("summary") or "Untitled"
391
+
392
+ base = {
393
+ "title": f"[SLA {result['sla_status'].upper()}] Ticket #{ticket_id}",
394
+ "subject": subject,
395
+ "priority": priority,
396
+ "status": result["sla_status"],
397
+ "escalation_level": result["escalation_level"],
398
+ "remaining_seconds": result["remaining_seconds"],
399
+ "ticket_url": f"https://helpdeskaiv1.vercel.app/admin/ticket/{ticket['id']}",
400
+ "assigned_team": ticket.get("assigned_team", "Unassigned"),
401
+ "timestamp": datetime.datetime.utcnow().isoformat() + "Z",
402
+ }
403
+
404
+ if channel_type == ChannelType.SLACK:
405
+ color = "#ef4444" if result["sla_status"] == "breached" else "#f59e0b"
406
+ return {
407
+ "attachments": [{
408
+ "color": color,
409
+ "title": base["title"],
410
+ "text": f"*Ticket:* <{base['ticket_url']}|#{ticket_id}> — {subject}\n"
411
+ f"*Priority:* {priority} | *Team:* {base['assigned_team']}\n"
412
+ f"*Remaining:* {self._fmt_remaining(result['remaining_seconds'])}",
413
+ "footer": "HELPDESK.AI SLA Engine",
414
+ "ts": int(datetime.datetime.utcnow().timestamp()),
415
+ }]
416
+ }
417
+
418
+ elif channel_type == ChannelType.TEAMS:
419
+ fact_section = {
420
+ "facts": [
421
+ {"name": "Ticket", "value": f"[#{ticket_id}]({base['ticket_url']})"},
422
+ {"name": "Subject", "value": subject},
423
+ {"name": "Priority", "value": priority},
424
+ {"name": "Team", "value": base["assigned_team"]},
425
+ {"name": "SLA Status", "value": result["sla_status"].upper()},
426
+ {"name": "Escalation Level", "value": str(result["escalation_level"])},
427
+ ]
428
+ }
429
+ return {
430
+ "@type": "MessageCard",
431
+ "@context": "https://schema.org/extensions",
432
+ "summary": base["title"],
433
+ "themeColor": color if result["sla_status"] == "breached" else "f59e0b",
434
+ "sections": [{
435
+ "activityTitle": base["title"],
436
+ "activitySubtitle": subject,
437
+ **fact_section,
438
+ }],
439
+ }
440
+
441
+ elif channel_type == ChannelType.EMAIL:
442
+ return {
443
+ "type": "SLA_ALERT",
444
+ "to": channel.get("to", "support@helpdeskai.com"),
445
+ "subject": base["title"],
446
+ "template_data": {
447
+ "title": f"SLA {result['sla_status'].upper()}: Ticket #{ticket_id}",
448
+ "badge": f"🚨 Escalation Level {result['escalation_level']}",
449
+ "mainText": f"Priority {priority} ticket \"{subject}\" has reached SLA status: {result['sla_status']}.",
450
+ "refLabel": "Time Remaining",
451
+ "refValue": self._fmt_remaining(result["remaining_seconds"]),
452
+ "ctaText": "View Ticket",
453
+ "ctaUrl": base["ticket_url"],
454
+ }
455
+ }
456
+
457
+ # Generic webhook fallback
458
+ return base
459
+
460
+ @staticmethod
461
+ def _fmt_remaining(seconds: int) -> str:
462
+ """Format seconds into human-readable remaining time."""
463
+ if seconds <= 0:
464
+ return "OVERDUE"
465
+ hrs = seconds // 3600
466
+ mins = (seconds % 3600) // 60
467
+ if hrs > 0:
468
+ return f"{hrs}h {mins}m"
469
+ return f"{mins}m"
470
+
471
+ # ------------------------------------------------------------------
472
+ # SLA Dashboard stats
473
+ # ------------------------------------------------------------------
474
+
475
+ async def get_dashboard_stats(self) -> dict:
476
+ """Aggregate SLA statistics for dashboard display."""
477
+ if not self.supabase:
478
+ return {"error": "No database connection"}
479
+
480
+ try:
481
+ res = self.supabase.table("tickets") \
482
+ .select("id, priority, sla_status, status, escalation_level") \
483
+ .execute()
484
+ except Exception as e:
485
+ return {"error": str(e)}
486
+
487
+ tickets = res.data or []
488
+ total = len(tickets)
489
+ active_tickets = [t for t in tickets if not any(
490
+ s in (t.get("status") or "").lower() for s in ["resolv", "closed"]
491
+ )]
492
+
493
+ counts = {
494
+ "total": total,
495
+ "active": len(active_tickets),
496
+ "breached": sum(1 for t in tickets if t.get("sla_status") == "breached"),
497
+ "warning": sum(1 for t in tickets if t.get("sla_status") == "warning"),
498
+ "met": sum(1 for t in tickets if t.get("sla_status") == "met"),
499
+ "by_priority": {},
500
+ "breach_rate": 0,
501
+ }
502
+
503
+ if total > 0:
504
+ counts["breach_rate"] = round(counts["breached"] / total * 100, 1)
505
+
506
+ for p in ["critical", "high", "medium", "low"]:
507
+ ptickets = [t for t in active_tickets if (t.get("priority") or "").lower() == p]
508
+ counts["by_priority"][p] = {
509
+ "total": len(ptickets),
510
+ "breached": sum(1 for t in ptickets if t.get("sla_status") == "breached"),
511
+ "warning": sum(1 for t in ptickets if t.get("sla_status") == "warning"),
512
+ }
513
+
514
+ return counts
515
+
516
+
517
+ # ---------------------------------------------------------------------------
518
+ # Standalone helper — get policy for a priority
519
+ # ---------------------------------------------------------------------------
520
+
521
+ def get_sla_policy(priority: str) -> SLAMetric:
522
+ """Get the SLA policy for a given priority string."""
523
+ return SLA_POLICIES.get(priority.lower().strip(), SLA_POLICIES["medium"])
524
+
525
+
526
+ def compute_sla_breach_at(priority: str, from_time: datetime.datetime | None = None) -> str:
527
+ """
528
+ Compute the ISO datetime when this ticket's SLA would breach.
529
+ Returns ISO string.
530
+ """
531
+ policy = get_sla_policy(priority)
532
+ start = from_time or datetime.datetime.now(datetime.timezone.utc)
533
+ breach = start + datetime.timedelta(hours=policy["max_hours"])
534
+ return breach.isoformat()
backend/sla_checker.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SLA Background Checker — Periodic worker that evaluates ticket SLAs
3
+ and dispatches multi-channel escalation notifications.
4
+
5
+ Run as a standalone process:
6
+ python backend/sla_checker.py
7
+
8
+ Or import and use the start_background_checker() function.
9
+ """
10
+
11
+ import os
12
+ import sys
13
+ import time
14
+ import json
15
+ import asyncio
16
+ import logging
17
+ import datetime
18
+ from pathlib import Path
19
+
20
+ # ── Project Path ──────────────────────────────────────────────────────────
21
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
22
+
23
+ # ── Logging ────────────────────────────────────────────────────────────────
24
+ logging.basicConfig(
25
+ level=logging.INFO,
26
+ format="[SLA-Checker %(asctime)s] %(levelname)s %(message)s",
27
+ datefmt="%Y-%m-%d %H:%M:%S",
28
+ )
29
+ logger = logging.getLogger("sla-checker")
30
+
31
+ # ── Load env ───────────────────────────────────────────────────────────────
32
+ from dotenv import load_dotenv
33
+ env_path = Path(__file__).parent / '.env'
34
+ load_dotenv(dotenv_path=env_path)
35
+
36
+
37
+ def create_supabase_client():
38
+ """Create a Supabase client using service role key."""
39
+ try:
40
+ from supabase import create_client
41
+ url = os.environ.get("SUPABASE_URL")
42
+ key = os.environ.get("SUPABASE_SERVICE_KEY")
43
+ if not url or not key:
44
+ logger.error("SUPABASE_URL or SUPABASE_SERVICE_KEY not set")
45
+ return None
46
+ return create_client(url, key)
47
+ except ImportError:
48
+ logger.warning("supabase-py not installed — running in dry-run mode")
49
+ return None
50
+ except Exception as e:
51
+ logger.error(f"Failed to create Supabase client: {e}")
52
+ return None
53
+
54
+
55
+ async def run_sla_check(supabase=None, dry_run: bool = False) -> dict:
56
+ """
57
+ Execute one full SLA check cycle.
58
+ Returns a summary dict with counts.
59
+ """
60
+ from backend.services.sla_engine import SLAEngine
61
+
62
+ engine = SLAEngine(supabase_client=supabase)
63
+ logger.info("Starting SLA check cycle...")
64
+
65
+ if dry_run:
66
+ logger.info("DRY RUN mode — no DB updates will be made.")
67
+
68
+ if supabase and not dry_run:
69
+ escalated = await engine.check_all_active_tickets()
70
+ if escalated:
71
+ logger.info(f"Dispatching notifications for {len(escalated)} escalated tickets...")
72
+ await engine.dispatch_notifications(escalated)
73
+ stats = await engine.get_dashboard_stats()
74
+ logger.info(f"SLA stats: {json.dumps(stats)}")
75
+ return {"checked": len(escalated) if escalated else 0, "stats": stats}
76
+ else:
77
+ logger.warning("No Supabase client — cannot run SLA check.")
78
+ return {"checked": 0, "stats": {}}
79
+
80
+
81
+ def run_sla_check_sync(supabase=None, dry_run: bool = False) -> dict:
82
+ """Synchronous wrapper for run_sla_check."""
83
+ return asyncio.run(run_sla_check(supabase, dry_run))
84
+
85
+
86
+ def background_checker_loop(interval_minutes: int = 5, dry_run: bool = False):
87
+ """
88
+ Run the SLA check in an infinite loop.
89
+
90
+ Args:
91
+ interval_minutes: How often to check (default 5 min).
92
+ dry_run: If True, only log what would be done.
93
+ """
94
+ supabase = create_supabase_client()
95
+ logger.info(
96
+ f"SLA Background Checker started "
97
+ f"(interval={interval_minutes}m, dry_run={dry_run})"
98
+ )
99
+
100
+ while True:
101
+ try:
102
+ result = run_sla_check_sync(supabase, dry_run)
103
+ logger.info(f"Cycle complete: {json.dumps(result)}")
104
+ except Exception as e:
105
+ logger.error(f"Cycle failed: {e}", exc_info=True)
106
+
107
+ logger.info(f"Sleeping for {interval_minutes} minutes...")
108
+ time.sleep(interval_minutes * 60)
109
+
110
+
111
+ # # ── FastAPI lifespan integration ──────────────────────────────────────
112
+ # Use this inside lifespan() to start the checker as a background task:
113
+ #
114
+ # task = asyncio.create_task(sla_checker_loop_async(app.state.supabase))
115
+ #
116
+ # (Not a standalone asyncio loop to avoid blocking the server.)
117
+
118
+ async def sla_checker_loop_async(
119
+ supabase,
120
+ interval_seconds: int = 300,
121
+ ):
122
+ """Async SLA checker loop — suitable for FastAPI lifespan background task."""
123
+ from backend.services.sla_engine import SLAEngine
124
+ engine = SLAEngine(supabase_client=supabase)
125
+ logger.info(f"SLA background async checker started (interval={interval_seconds}s)")
126
+
127
+ while True:
128
+ try:
129
+ escalated = await engine.check_all_active_tickets()
130
+ if escalated:
131
+ await engine.dispatch_notifications(escalated)
132
+ logger.info(f"Async SLA check: {len(escalated)} escalations triggered")
133
+ except Exception as e:
134
+ logger.error(f"Async SLA check error: {e}")
135
+
136
+ await asyncio.sleep(interval_seconds)
137
+
138
+
139
+ # ── CLI entry point ──────────────────────────────────────────────────────
140
+ if __name__ == "__main__":
141
+ import argparse
142
+ parser = argparse.ArgumentParser(description="SLA Breach & Escalation Checker")
143
+ parser.add_argument(
144
+ "--interval", type=int, default=5,
145
+ help="Check interval in minutes (default: 5)"
146
+ )
147
+ parser.add_argument(
148
+ "--dry-run", action="store_true",
149
+ help="Log evaluations without updating the database"
150
+ )
151
+ parser.add_argument(
152
+ "--once", action="store_true",
153
+ help="Run a single check cycle and exit"
154
+ )
155
+ args = parser.parse_args()
156
+
157
+ if args.once:
158
+ supabase = create_supabase_client()
159
+ result = run_sla_check_sync(supabase, args.dry_run)
160
+ print(json.dumps(result, indent=2))
161
+ else:
162
+ background_checker_loop(args.interval, args.dry_run)
supabase/functions/sla-notifier/index.ts ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { serve } from "https://deno.land/std@0.177.0/http/server.ts"
2
+ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.42.0"
3
+
4
+ /**
5
+ * SLA Notifier — Multi-channel notification dispatcher for SLA breaches.
6
+ *
7
+ * Triggered by Supabase Database webhook when:
8
+ * 1. A ticket's sla_status changes to 'breached' or 'warning'
9
+ * 2. An escalation_logs row is inserted
10
+ *
11
+ * Supported channels:
12
+ * - Email (via Resend)
13
+ * - Slack (incoming webhook)
14
+ * - Microsoft Teams (incoming webhook)
15
+ * - Generic webhook
16
+ */
17
+
18
+ const SUPABASE_URL = Deno.env.get("SUPABASE_URL") || ""
19
+ const SUPABASE_SERVICE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || ""
20
+ const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY") || ""
21
+ const FROM_EMAIL = "HELPDESK.AI SLA <bonthalamadhavi1@gmail.com>"
22
+
23
+ // ── Channel configuration from environment ────────────────────────────────
24
+ // Format: JSON array of channel objects
25
+ // SLA_CHANNELS=[{"type":"slack","url":"https://hooks.slack.com/...","enabled":true,"min_level":1}]
26
+ const SLA_CHANNELS_RAW = Deno.env.get("SLA_CHANNELS") || "[]"
27
+ let SLA_CHANNELS: any[] = []
28
+ try {
29
+ SLA_CHANNELS = JSON.parse(SLA_CHANNELS_RAW)
30
+ } catch { /* use empty */ }
31
+
32
+ // ── Helpers ────────────────────────────────────────────────────────────────
33
+
34
+ function formatDuration(seconds: number): string {
35
+ if (seconds <= 0) return "OVERDUE"
36
+ const hrs = Math.floor(seconds / 3600)
37
+ const mins = Math.floor((seconds % 3600) / 60)
38
+ if (hrs > 0) return `${hrs}h ${mins}m`
39
+ return `${mins}m`
40
+ }
41
+
42
+ function getColor(status: string): string {
43
+ if (status === "breached") return "#ef4444" // red
44
+ if (status === "warning") return "#f59e0b" // amber
45
+ return "#10b981" // green
46
+ }
47
+
48
+ // ── Channel dispatchers ────────────────────────────────────────────────────
49
+
50
+ async function sendEmail(payload: any): Promise<boolean> {
51
+ if (!RESEND_API_KEY) {
52
+ console.warn("[SLA-Notifier] No RESEND_API_KEY configured — skipping email.")
53
+ return false
54
+ }
55
+
56
+ const templateData = payload.template_data || {}
57
+ const recipient = payload.to || "support@helpdeskai.com"
58
+
59
+ const themeColor = "#ef4444"
60
+ const html = `<!DOCTYPE html>
61
+ <html>
62
+ <head>
63
+ <meta charset="UTF-8">
64
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
65
+ </head>
66
+ <body style="margin:0;padding:0;background-color:#f1f5f9;font-family:sans-serif;">
67
+ <table width="100%" border="0" cellspacing="0" cellpadding="0" style="background-color:#f1f5f9;padding:40px 20px;">
68
+ <tr>
69
+ <td align="center">
70
+ <table width="100%" border="0" cellspacing="0" cellpadding="0" style="max-width:600px;background-color:#ffffff;border-radius:24px;overflow:hidden;box-shadow:0 10px 40px rgba(0,0,0,0.05);">
71
+ <tr>
72
+ <td style="background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%); padding: 40px; text-align: center;">
73
+ <h1 style="color:#ffffff; margin:0; font-size:28px; font-weight:900;">HELPDESK<span style="color:#fca5a5;">.AI</span></h1>
74
+ <p style="color:#fecaca; margin:8px 0 0; font-size:14px;">SLA Alert System</p>
75
+ </td>
76
+ </tr>
77
+ <tr>
78
+ <td style="padding: 24px 40px; border-bottom: 1px solid #f1f5f9; background-color: #fef2f2; text-align: center;">
79
+ <div style="display:inline-block; padding: 8px 16px; background-color: #fef2f2; border-radius: 999px; border: 1px solid #fecaca;">
80
+ <p style="margin:0; color:#991b1b; font-size:12px; font-weight:800; text-transform:uppercase;">${templateData.badge || "SLA NOTIFICATION"}</p>
81
+ </div>
82
+ </td>
83
+ </tr>
84
+ <tr>
85
+ <td style="padding: 40px;">
86
+ <h2 style="color:#0f172a; font-size:24px; margin: 0 0 16px;">${templateData.title || "SLA Alert"}</h2>
87
+ <p style="color:#64748b; font-size:16px; line-height:1.7; margin: 0 0 32px;">${templateData.mainText || ""}</p>
88
+
89
+ <div style="background-color: #0f172a; border-radius: 20px; padding: 32px; text-align:center; margin-bottom: 32px;">
90
+ <p style="margin:0; color:rgba(255,255,255,0.4); font-size:10px; font-weight:800; text-transform:uppercase;">${templateData.refLabel || "Status"}</p>
91
+ <h2 style="margin:8px 0 0; color:#ffffff; font-size:32px; font-weight:900; letter-spacing:0.1em;">${templateData.refValue || ""}</h2>
92
+ </div>
93
+
94
+ <div align="center">
95
+ <a href="${templateData.ctaUrl || "https://helpdeskaiv1.vercel.app/admin/tickets"}" style="display:inline-block; background-color:#dc2626; color:#ffffff; padding: 18px 40px; border-radius: 16px; text-decoration:none; font-size:14px; font-weight:900;">
96
+ ${templateData.ctaText || "View in Dashboard"} →
97
+ </a>
98
+ </div>
99
+ </td>
100
+ </tr>
101
+ <tr>
102
+ <td style="background-color:#f8fafc; padding:32px; text-align:center; border-top: 1px solid #f1f5f9;">
103
+ <p style="margin:0; color:#94a3b8; font-size:12px;">© 2026 HELPDESK.AI SLA Engine</p>
104
+ </td>
105
+ </tr>
106
+ </table>
107
+ </td>
108
+ </tr>
109
+ </table>
110
+ </body>
111
+ </html>`
112
+
113
+ try {
114
+ const res = await fetch("https://api.resend.com/emails", {
115
+ method: "POST",
116
+ headers: {
117
+ "Authorization": `Bearer ${RESEND_API_KEY}`,
118
+ "Content-Type": "application/json",
119
+ },
120
+ body: JSON.stringify({
121
+ from: FROM_EMAIL,
122
+ to: [recipient],
123
+ subject: payload.subject || "[SLA Alert] HELPDESK.AI",
124
+ html: html,
125
+ }),
126
+ })
127
+ const data = await res.json()
128
+ console.log(`[SLA-Notifier] Email sent to ${recipient}: ${res.status}`, data)
129
+ return res.status < 500
130
+ } catch (err) {
131
+ console.error("[SLA-Notifier] Email send error:", err)
132
+ return false
133
+ }
134
+ }
135
+
136
+ async function sendSlack(payload: any): Promise<boolean> {
137
+ try {
138
+ const res = await fetch(payload.url, {
139
+ method: "POST",
140
+ headers: { "Content-Type": "application/json" },
141
+ body: JSON.stringify(payload.body || {}),
142
+ })
143
+ console.log(`[SLA-Notifier] Slack sent: ${res.status}`)
144
+ return res.ok
145
+ } catch (err) {
146
+ console.error("[SLA-Notifier] Slack error:", err)
147
+ return false
148
+ }
149
+ }
150
+
151
+ async function sendTeams(payload: any): Promise<boolean> {
152
+ try {
153
+ const res = await fetch(payload.url, {
154
+ method: "POST",
155
+ headers: { "Content-Type": "application/json" },
156
+ body: JSON.stringify(payload.body || {}),
157
+ })
158
+ console.log(`[SLA-Notifier] Teams sent: ${res.status}`)
159
+ return res.ok
160
+ } catch (err) {
161
+ console.error("[SLA-Notifier] Teams error:", err)
162
+ return false
163
+ }
164
+ }
165
+
166
+ async function sendWebhook(payload: any): Promise<boolean> {
167
+ try {
168
+ const res = await fetch(payload.url, {
169
+ method: "POST",
170
+ headers: { "Content-Type": "application/json" },
171
+ body: JSON.stringify(payload.body || {}),
172
+ })
173
+ console.log(`[SLA-Notifier] Webhook sent: ${res.status}`)
174
+ return res.ok
175
+ } catch (err) {
176
+ console.error("[SLA-Notifier] Webhook error:", err)
177
+ return false
178
+ }
179
+ }
180
+
181
+ // ── Build a Slack-compatible attachment ────────────────────────────────────
182
+
183
+ function buildSlackMessage(ticket: any, slaResult: any): any {
184
+ const status = slaResult?.sla_status || "breached"
185
+ const level = slaResult?.escalation_level || 1
186
+ const color = getColor(status)
187
+ const ticketId = (ticket?.id || "???").toString().slice(0, 8).toUpperCase()
188
+ const subject = ticket?.subject || ticket?.summary || "Untitled"
189
+ const priority = (ticket?.priority || "medium").toUpperCase()
190
+ const team = ticket?.assigned_team || "Unassigned"
191
+
192
+ return {
193
+ attachments: [{
194
+ color,
195
+ title: `[SLA ${status.toUpperCase()}] Ticket #${ticketId}`,
196
+ text: [
197
+ `*Ticket:* #${ticketId} — ${subject}`,
198
+ `*Priority:* ${priority} | *Team:* ${team}`,
199
+ `*Escalation Level:* ${level}`,
200
+ `*Time Remaining:* ${formatDuration(slaResult?.remaining_seconds || 0)}`,
201
+ ].join("\n"),
202
+ footer: "HELPDESK.AI SLA Engine",
203
+ ts: Math.floor(Date.now() / 1000),
204
+ }],
205
+ }
206
+ }
207
+
208
+ function buildTeamsMessage(ticket: any, slaResult: any): any {
209
+ const ticketId = (ticket?.id || "???").toString().slice(0, 8).toUpperCase()
210
+ const color = getColor(slaResult?.sla_status || "breached")
211
+ return {
212
+ "@type": "MessageCard",
213
+ "@context": "https://schema.org/extensions",
214
+ summary: `[SLA ${(slaResult?.sla_status || "").toUpperCase()}] Ticket #${ticketId}`,
215
+ themeColor: color,
216
+ sections: [{
217
+ activityTitle: `SLA Alert: Ticket #${ticketId}`,
218
+ activitySubtitle: ticket?.subject || ticket?.summary || "",
219
+ facts: [
220
+ { name: "Status", value: (slaResult?.sla_status || "").toUpperCase() },
221
+ { name: "Priority", value: (ticket?.priority || "medium").toUpperCase() },
222
+ { name: "Level", value: String(slaResult?.escalation_level || 0) },
223
+ { name: "Team", value: ticket?.assigned_team || "Unassigned" },
224
+ { name: "Remaining", value: formatDuration(slaResult?.remaining_seconds || 0) },
225
+ ],
226
+ }],
227
+ }
228
+ }
229
+
230
+ // ── Main handler ───────────────────────────────────────────────────────────
231
+
232
+ serve(async (req: Request) => {
233
+ try {
234
+ const payload = await req.json()
235
+
236
+ // Support both webhook trigger and direct API call
237
+ const { type, record, ticket, sla_result, channel_urls } = payload
238
+ const isWebhook = type === "INSERT" && record?.table === "escalation_logs"
239
+
240
+ if (isWebhook || type === "SLA_ALERT") {
241
+ const escalationRecord = isWebhook ? record : payload
242
+ const targetTicket = ticket || escalationRecord
243
+
244
+ // Determine which channels to use
245
+ const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY)
246
+
247
+ // Build common notification payload
248
+ const ticketId = (targetTicket.id || targetTicket.ticket_id || "???").toString().slice(0, 8)
249
+ const status = sla_result?.sla_status || escalationRecord.sla_status || "breached"
250
+ const level = sla_result?.escalation_level || escalationRecord.escalation_level || 1
251
+ const subject = targetTicket.subject || targetTicket.ticket_subject || "Untitled"
252
+
253
+ // Dispatch to configured channels
254
+ const results: string[] = []
255
+
256
+ for (const channel of SLA_CHANNELS) {
257
+ if (!channel.enabled) continue
258
+ if ((channel.min_level || 0) > level) continue
259
+
260
+ const channelType = channel.type || "webhook"
261
+ let success = false
262
+
263
+ switch (channelType) {
264
+ case "email": {
265
+ const emailPayload = {
266
+ to: channel.to || "support@helpdeskai.com",
267
+ subject: `[SLA ${status.toUpperCase()}] Ticket #${ticketId}`,
268
+ template_data: {
269
+ title: `${status.toUpperCase()}: Ticket #${ticketId}`,
270
+ badge: `🚨 Escalation Level ${level}`,
271
+ mainText: `Priority ${(targetTicket.priority || "medium").toUpperCase()} ticket "${subject}" has reached SLA status: ${status}.`,
272
+ refLabel: "Time Remaining",
273
+ refValue: formatDuration(sla_result?.remaining_seconds || 0),
274
+ ctaText: "View Ticket",
275
+ ctaUrl: `https://helpdeskaiv1.vercel.app/admin/ticket/${targetTicket.id || ""}`,
276
+ },
277
+ }
278
+ success = await sendEmail(emailPayload)
279
+ break
280
+ }
281
+ case "slack":
282
+ success = await sendSlack({
283
+ url: channel.url,
284
+ body: buildSlackMessage(targetTicket, sla_result),
285
+ })
286
+ break
287
+ case "teams":
288
+ success = await sendTeams({
289
+ url: channel.url,
290
+ body: buildTeamsMessage(targetTicket, sla_result),
291
+ })
292
+ break
293
+ case "webhook":
294
+ default:
295
+ success = await sendWebhook({
296
+ url: channel.url,
297
+ body: {
298
+ type: "sla_alert",
299
+ ticket_id: targetTicket.id || targetTicket.ticket_id,
300
+ sla_status: status,
301
+ escalation_level: level,
302
+ subject,
303
+ remaining_seconds: sla_result?.remaining_seconds || 0,
304
+ timestamp: new Date().toISOString(),
305
+ },
306
+ })
307
+ break
308
+ }
309
+
310
+ results.push(`${channelType}:${success ? "OK" : "FAIL"}`)
311
+ }
312
+
313
+ // Log dispatched channels back to the escalation_logs record
314
+ if (isWebhook && escalationRecord.id) {
315
+ try {
316
+ await supabase
317
+ .from("escalation_logs")
318
+ .update({ notification_channels: results })
319
+ .eq("id", escalationRecord.id)
320
+ } catch { /* non-fatal */ }
321
+ }
322
+
323
+ return new Response(JSON.stringify({
324
+ status: "dispatched",
325
+ channels: results,
326
+ ticket_id: targetTicket.id || targetTicket.ticket_id,
327
+ }), { status: 200 })
328
+ }
329
+
330
+ // Health check
331
+ if (type === "HEALTH") {
332
+ return new Response(JSON.stringify({
333
+ status: "ok",
334
+ channels_configured: SLA_CHANNELS.length,
335
+ }), { status: 200 })
336
+ }
337
+
338
+ return new Response(JSON.stringify({ status: "ignored", reason: "unknown_type" }), { status: 200 })
339
+ } catch (err: any) {
340
+ console.error("[SLA-Notifier] Error:", err.message)
341
+ return new Response(JSON.stringify({ error: err.message }), { status: 500 })
342
+ }
343
+ })
supabase/migrations/20260401000000_sla_engine.sql ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- SLA Engine — Database schema for SLA breach detection and escalation
2
+ -- Part of Issue #75: SLA Breach & Automated Multi-Channel Escalation Engine
3
+
4
+ -- =========================================================================
5
+ -- 1. Extend the tickets table with SLA columns
6
+ -- =========================================================================
7
+
8
+ ALTER TABLE public.tickets
9
+ ADD COLUMN IF NOT EXISTS sla_status text
10
+ DEFAULT 'active'
11
+ CHECK (sla_status IN ('active', 'warning', 'breached', 'met', 'paused'));
12
+
13
+ ALTER TABLE public.tickets
14
+ ADD COLUMN IF NOT EXISTS sla_policy text
15
+ DEFAULT 'medium'
16
+ CHECK (sla_policy IN ('critical', 'high', 'medium', 'low'));
17
+
18
+ ALTER TABLE public.tickets
19
+ ADD COLUMN IF NOT EXISTS sla_breach_at timestamptz;
20
+
21
+ ALTER TABLE public.tickets
22
+ ADD COLUMN IF NOT EXISTS sla_warning_at timestamptz;
23
+
24
+ ALTER TABLE public.tickets
25
+ ADD COLUMN IF NOT EXISTS sla_started_at timestamptz;
26
+
27
+ ALTER TABLE public.tickets
28
+ ADD COLUMN IF NOT EXISTS sla_updated_at timestamptz;
29
+
30
+ ALTER TABLE public.tickets
31
+ ADD COLUMN IF NOT EXISTS escalation_level integer
32
+ DEFAULT 0
33
+ CHECK (escalation_level >= 0 AND escalation_level <= 3);
34
+
35
+ ALTER TABLE public.tickets
36
+ ADD COLUMN IF NOT EXISTS last_escalated_at timestamptz;
37
+
38
+ ALTER TABLE public.tickets
39
+ ADD COLUMN IF NOT EXISTS remaining_seconds integer
40
+ DEFAULT 0;
41
+
42
+ ALTER TABLE public.tickets
43
+ ADD COLUMN IF NOT EXISTS channel_notified text[]
44
+ DEFAULT '{}';
45
+
46
+ -- =========================================================================
47
+ -- 2. Escalation Logs — audit trail for every SLA escalation event
48
+ -- =========================================================================
49
+
50
+ CREATE TABLE IF NOT EXISTS public.escalation_logs (
51
+ id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
52
+ ticket_id uuid REFERENCES public.tickets(id) ON DELETE CASCADE,
53
+ ticket_subject text NOT NULL DEFAULT '',
54
+ priority text NOT NULL DEFAULT 'medium',
55
+ sla_status text NOT NULL CHECK (sla_status IN ('active', 'warning', 'breached', 'met', 'paused')),
56
+ escalation_level integer NOT NULL DEFAULT 0,
57
+ remaining_seconds integer NOT NULL DEFAULT 0,
58
+ assigned_team text NOT NULL DEFAULT '',
59
+ notification_channels text[] DEFAULT '{}',
60
+ triggered_at timestamptz NOT NULL DEFAULT now(),
61
+ resolved_at timestamptz,
62
+ notes text DEFAULT ''
63
+ );
64
+
65
+ -- Index for fast dashboard queries
66
+ CREATE INDEX IF NOT EXISTS idx_escalation_logs_ticket_id ON public.escalation_logs(ticket_id);
67
+ CREATE INDEX IF NOT EXISTS idx_escalation_logs_triggered_at ON public.escalation_logs(triggered_at DESC);
68
+ CREATE INDEX IF NOT EXISTS idx_escalation_logs_sla_status ON public.escalation_logs(sla_status);
69
+
70
+ -- Enable RLS
71
+ ALTER TABLE public.escalation_logs ENABLE ROW LEVEL SECURITY;
72
+
73
+ -- Admins can read all escalation logs
74
+ CREATE POLICY "Admins can read escalation logs" ON public.escalation_logs
75
+ FOR SELECT
76
+ USING (
77
+ EXISTS (
78
+ SELECT 1 FROM public.profiles
79
+ WHERE profiles.id = auth.uid()
80
+ AND (profiles.role = 'admin' OR profiles.role = 'master_admin')
81
+ )
82
+ );
83
+
84
+ -- Service role and triggers can insert
85
+ CREATE POLICY "Service role can insert escalation logs" ON public.escalation_logs
86
+ FOR INSERT
87
+ WITH CHECK (true);
88
+
89
+ -- =========================================================================
90
+ -- 3. SLA Policies — configurable SLA definitions (optional override table)
91
+ -- =========================================================================
92
+
93
+ CREATE TABLE IF NOT EXISTS public.sla_policies (
94
+ id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
95
+ priority text NOT NULL UNIQUE CHECK (priority IN ('critical', 'high', 'medium', 'low')),
96
+ max_hours integer NOT NULL DEFAULT 8,
97
+ warning_pct numeric(4,3) NOT NULL DEFAULT 0.750,
98
+ auto_escalate boolean NOT NULL DEFAULT true,
99
+ l2_after_minutes integer NOT NULL DEFAULT 0,
100
+ l3_after_minutes integer NOT NULL DEFAULT 120,
101
+ company_id uuid REFERENCES public.companies(id) ON DELETE CASCADE,
102
+ is_custom boolean DEFAULT false,
103
+ created_at timestamptz NOT NULL DEFAULT now(),
104
+ updated_at timestamptz
105
+ );
106
+
107
+ -- Insert default SLA policies
108
+ INSERT INTO public.sla_policies (priority, max_hours, warning_pct, auto_escalate, l2_after_minutes, l3_after_minutes)
109
+ VALUES
110
+ ('critical', 2, 0.750, true, 0, 120),
111
+ ('high', 4, 0.750, true, 30, 240),
112
+ ('medium', 8, 0.750, true, 60, 480),
113
+ ('low', 24, 0.750, false, 120, 1440)
114
+ ON CONFLICT (priority) DO NOTHING;
115
+
116
+ ALTER TABLE public.sla_policies ENABLE ROW LEVEL SECURITY;
117
+
118
+ CREATE POLICY "Anyone can read sla_policies" ON public.sla_policies
119
+ FOR SELECT USING (true);
120
+
121
+ CREATE POLICY "Master admins can manage sla_policies" ON public.sla_policies
122
+ FOR ALL
123
+ USING (
124
+ EXISTS (
125
+ SELECT 1 FROM public.profiles
126
+ WHERE profiles.id = auth.uid()
127
+ AND profiles.role = 'master_admin'
128
+ )
129
+ );
130
+
131
+ -- =========================================================================
132
+ -- 4. Indexes on tickets for SLA queries
133
+ -- =========================================================================
134
+
135
+ CREATE INDEX IF NOT EXISTS idx_tickets_sla_status ON public.tickets(sla_status);
136
+ CREATE INDEX IF NOT EXISTS idx_tickets_escalation_level ON public.tickets(escalation_level);
137
+ CREATE INDEX IF NOT EXISTS idx_tickets_sla_breach_at ON public.tickets(sla_breach_at);
138
+ CREATE INDEX IF NOT EXISTS idx_tickets_priority_sla ON public.tickets(priority, sla_status);
139
+
140
+ -- =========================================================================
141
+ -- 5. Trigger: auto-set sla_policy when priority changes
142
+ -- =========================================================================
143
+
144
+ CREATE OR REPLACE FUNCTION public.auto_set_sla_policy()
145
+ RETURNS trigger AS $$
146
+ BEGIN
147
+ NEW.sla_policy := LOWER(NEW.priority);
148
+ -- If SLA hasn't started yet, start it now
149
+ IF NEW.sla_started_at IS NULL THEN
150
+ NEW.sla_started_at := now();
151
+ END IF;
152
+ RETURN NEW;
153
+ END;
154
+ $$ LANGUAGE plpgsql;
155
+
156
+ DROP TRIGGER IF EXISTS trg_auto_set_sla_policy ON public.tickets;
157
+ CREATE TRIGGER trg_auto_set_sla_policy
158
+ BEFORE INSERT OR UPDATE OF priority ON public.tickets
159
+ FOR EACH ROW
160
+ EXECUTE FUNCTION public.auto_set_sla_policy();
161
+
162
+ -- =========================================================================
163
+ -- 6. Trigger: log when sla_status changes to breached
164
+ -- =========================================================================
165
+
166
+ CREATE OR REPLACE FUNCTION public.log_sla_breach()
167
+ RETURNS trigger AS $$
168
+ BEGIN
169
+ IF NEW.sla_status = 'breached' AND (OLD.sla_status IS DISTINCT FROM 'breached') THEN
170
+ INSERT INTO public.escalation_logs (
171
+ ticket_id, ticket_subject, priority, sla_status,
172
+ escalation_level, remaining_seconds, assigned_team,
173
+ notification_channels, triggered_at
174
+ ) VALUES (
175
+ NEW.id,
176
+ COALESCE(NEW.subject, NEW.summary, ''),
177
+ NEW.priority,
178
+ 'breached',
179
+ COALESCE(NEW.escalation_level, 1),
180
+ COALESCE(NEW.remaining_seconds, 0),
181
+ COALESCE(NEW.assigned_team, ''),
182
+ '{}',
183
+ now()
184
+ );
185
+ END IF;
186
+ RETURN NEW;
187
+ END;
188
+ $$ LANGUAGE plpgsql;
189
+
190
+ DROP TRIGGER IF EXISTS trg_log_sla_breach ON public.tickets;
191
+ CREATE TRIGGER trg_log_sla_breach
192
+ AFTER UPDATE OF sla_status ON public.tickets
193
+ FOR EACH ROW
194
+ WHEN (NEW.sla_status = 'breached')
195
+ EXECUTE FUNCTION public.log_sla_breach();
196
+
197
+ -- =========================================================================
198
+ -- 7. Grant permissions
199
+ -- =========================================================================
200
+
201
+ GRANT ALL ON TABLE public.escalation_logs TO authenticated;
202
+ GRANT ALL ON TABLE public.escalation_logs TO service_role;
203
+ GRANT SELECT ON TABLE public.sla_policies TO authenticated;
204
+ GRANT ALL ON TABLE public.sla_policies TO service_role;
supabase/migrations/20260402000000_semantic_duplicates.sql ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Semantic Duplicate Detection — pgvector-powered ticket similarity search
2
+ -- Issue #74: AI-Driven Semantic Duplicate Ticket Detection with Vector Embeddings
3
+
4
+ -- =========================================================================
5
+ -- 1. Enable pgvector extension
6
+ -- =========================================================================
7
+
8
+ CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA extensions;
9
+
10
+ -- =========================================================================
11
+ -- 2. Add vector column to tickets table
12
+ -- =========================================================================
13
+
14
+ ALTER TABLE public.tickets
15
+ ADD COLUMN IF NOT EXISTS description_vector vector(384);
16
+
17
+ -- Index for fast cosine similarity queries (IVFFlat is good for <100K rows)
18
+ CREATE INDEX IF NOT EXISTS idx_tickets_description_vector
19
+ ON public.tickets
20
+ USING ivfflat (description_vector vector_cosine_ops)
21
+ WITH (lists = 100);
22
+
23
+ -- =========================================================================
24
+ -- 3. Similarity search RPC — company-scoped cosine similarity
25
+ -- =========================================================================
26
+
27
+ -- Drop existing if present
28
+ DROP FUNCTION IF EXISTS match_tickets;
29
+
30
+ CREATE OR REPLACE FUNCTION match_tickets(
31
+ query_vector vector(384),
32
+ match_threshold float DEFAULT 0.70,
33
+ match_count int DEFAULT 5,
34
+ tenant_company_id uuid DEFAULT NULL
35
+ )
36
+ RETURNS TABLE(
37
+ id uuid,
38
+ ticket_id text,
39
+ subject text,
40
+ summary text,
41
+ description text,
42
+ priority text,
43
+ status text,
44
+ assigned_team text,
45
+ company_id uuid,
46
+ similarity float,
47
+ created_at timestamptz
48
+ )
49
+ LANGUAGE plpgsql
50
+ AS $$
51
+ BEGIN
52
+ RETURN QUERY
53
+ SELECT
54
+ t.id,
55
+ t.ticket_id,
56
+ t.subject,
57
+ t.summary,
58
+ t.description,
59
+ t.priority,
60
+ t.status,
61
+ t.assigned_team,
62
+ t.company_id,
63
+ 1 - (t.description_vector <=> query_vector) AS similarity,
64
+ t.created_at
65
+ FROM public.tickets t
66
+ WHERE
67
+ t.description_vector IS NOT NULL
68
+ AND (1 - (t.description_vector <=> query_vector)) > match_threshold
69
+ AND (tenant_company_id IS NULL OR t.company_id = tenant_company_id)
70
+ -- Exclude resolved/closed tickets from duplicate matching
71
+ AND (t.status IS NULL OR (
72
+ LOWER(t.status) NOT LIKE '%resolv%'
73
+ AND LOWER(t.status) NOT LIKE '%closed%'
74
+ ))
75
+ ORDER BY t.description_vector <=> query_vector
76
+ LIMIT match_count;
77
+ END;
78
+ $$;
79
+
80
+ -- =========================================================================
81
+ -- 4. System settings table for dynamic duplicate_sensitivity
82
+ -- =========================================================================
83
+
84
+ CREATE TABLE IF NOT EXISTS public.system_settings (
85
+ id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
86
+ key text NOT NULL UNIQUE,
87
+ value jsonb NOT NULL DEFAULT '{}'::jsonb,
88
+ description text DEFAULT '',
89
+ updated_at timestamptz DEFAULT now()
90
+ );
91
+
92
+ -- Insert default duplicate sensitivity
93
+ INSERT INTO public.system_settings (key, value, description)
94
+ VALUES (
95
+ 'duplicate_detection',
96
+ '{"sensitivity": 0.85, "enabled": true, "max_candidates": 5}'::jsonb,
97
+ 'Duplicate detection configuration: sensitivity (0.0-1.0), enabled, max_candidates'
98
+ )
99
+ ON CONFLICT (key) DO NOTHING;
100
+
101
+ -- =========================================================================
102
+ -- 5. Add parent_ticket_id for linking duplicates
103
+ -- =========================================================================
104
+
105
+ ALTER TABLE public.tickets
106
+ ADD COLUMN IF NOT EXISTS parent_ticket_id uuid REFERENCES public.tickets(id) ON DELETE SET NULL;
107
+
108
+ ALTER TABLE public.tickets
109
+ ADD COLUMN IF NOT EXISTS is_potential_duplicate boolean DEFAULT false;
110
+
111
+ -- Index for finding duplicate chains
112
+ CREATE INDEX IF NOT EXISTS idx_tickets_parent_ticket_id ON public.tickets(parent_ticket_id);
113
+
114
+ -- =========================================================================
115
+ -- 6. RLS for system_settings
116
+ -- =========================================================================
117
+
118
+ ALTER TABLE public.system_settings ENABLE ROW LEVEL SECURITY;
119
+
120
+ CREATE POLICY "Anyone can read system settings" ON public.system_settings
121
+ FOR SELECT USING (true);
122
+
123
+ CREATE POLICY "Master admins can manage system settings" ON public.system_settings
124
+ FOR ALL
125
+ USING (
126
+ EXISTS (
127
+ SELECT 1 FROM public.profiles
128
+ WHERE profiles.id = auth.uid()
129
+ AND profiles.role = 'master_admin'
130
+ )
131
+ );
132
+
133
+ -- =========================================================================
134
+ -- 7. Grant permissions
135
+ -- =========================================================================
136
+
137
+ GRANT ALL ON ALL FUNCTIONS IN SCHEMA public TO authenticated;
138
+ GRANT ALL ON ALL FUNCTIONS IN SCHEMA public TO service_role;
139
+ GRANT ALL ON TABLE public.system_settings TO authenticated;
140
+ GRANT ALL ON TABLE public.system_settings TO service_role;