feat(xos): Phase 4 — real-time Socket.IO, SLA scheduler, business hours, campaigns
- server/socket-io.ts: singleton SocketServer shared across all modules - server/routes.ts: wire initSocketIO before chat/community sockets; start XOS scheduler - server/xos/scheduler.ts: SLA breach checker (every 5min) + supervisor stats broadcaster (every 30s) via broadcastXos - server/xos/routes.ts: campaign segmentation (POST /campaigns/segment), CRUD for xos_campaigns - server/whatsapp/service.ts: XOS queue-based business hours enforcement (xosQueueId in AutoReplyConfig), checkXosQueueIsOpen() helper - client/src/hooks/use-xos-socket.ts: useXosSocket hook — subscribes to xos:supervisor.stats and xos:sla.breach Socket.IO events - client/src/pages/XosSupervisor.tsx: integrate useXosSocket for real-time KPI cards; show connection mode (tempo real vs polling) - shared/schema.ts: fix missing xosDevPipelines pgTable header https://claude.ai/code/session_01DinH3VcgbAv1d9MqnNxzdb
This commit is contained in:
parent
5aaf94df0e
commit
07fa5e5871
|
|
@ -0,0 +1,103 @@
|
||||||
|
/**
|
||||||
|
* useXosSocket — subscribes to XOS real-time events from Socket.IO.
|
||||||
|
* Usage:
|
||||||
|
* const { supervisorStats, lastSlaBreachEvent } = useXosSocket();
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
import { io, Socket } from "socket.io-client";
|
||||||
|
|
||||||
|
export interface SupervisorStats {
|
||||||
|
openConversations: number;
|
||||||
|
resolvedToday: number;
|
||||||
|
urgentTickets: number;
|
||||||
|
agentsOnline: number;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlaBreachEvent {
|
||||||
|
protocolId: number;
|
||||||
|
protocolNumber: string;
|
||||||
|
tenantId: number;
|
||||||
|
queueId: number | null;
|
||||||
|
assignedTo: number | null;
|
||||||
|
breachedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface XosSocketState {
|
||||||
|
connected: boolean;
|
||||||
|
supervisorStats: SupervisorStats | null;
|
||||||
|
lastSlaBreachEvent: SlaBreachEvent | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _socket: Socket | null = null;
|
||||||
|
let _refCount = 0;
|
||||||
|
|
||||||
|
function getSocket(): Socket {
|
||||||
|
if (!_socket) {
|
||||||
|
_socket = io(window.location.origin, {
|
||||||
|
path: "/socket.io",
|
||||||
|
transports: ["websocket", "polling"],
|
||||||
|
reconnectionAttempts: 10,
|
||||||
|
reconnectionDelay: 2000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useXosSocket() {
|
||||||
|
const [state, setState] = useState<XosSocketState>({
|
||||||
|
connected: false,
|
||||||
|
supervisorStats: null,
|
||||||
|
lastSlaBreachEvent: null,
|
||||||
|
});
|
||||||
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
|
||||||
|
const handleStats = useCallback((data: SupervisorStats) => {
|
||||||
|
setState((s) => ({ ...s, supervisorStats: data }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSlaBreach = useCallback((data: SlaBreachEvent) => {
|
||||||
|
setState((s) => ({ ...s, lastSlaBreachEvent: data }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConnect = useCallback(() => {
|
||||||
|
setState((s) => ({ ...s, connected: true }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDisconnect = useCallback(() => {
|
||||||
|
setState((s) => ({ ...s, connected: false }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = getSocket();
|
||||||
|
socketRef.current = socket;
|
||||||
|
_refCount++;
|
||||||
|
|
||||||
|
socket.on("connect", handleConnect);
|
||||||
|
socket.on("disconnect", handleDisconnect);
|
||||||
|
socket.on("xos:supervisor.stats", handleStats);
|
||||||
|
socket.on("xos:sla.breach", handleSlaBreach);
|
||||||
|
|
||||||
|
// Sync initial connection state
|
||||||
|
if (socket.connected) {
|
||||||
|
setState((s) => ({ ...s, connected: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off("connect", handleConnect);
|
||||||
|
socket.off("disconnect", handleDisconnect);
|
||||||
|
socket.off("xos:supervisor.stats", handleStats);
|
||||||
|
socket.off("xos:sla.breach", handleSlaBreach);
|
||||||
|
|
||||||
|
_refCount--;
|
||||||
|
// Keep socket alive as long as any component uses it
|
||||||
|
if (_refCount <= 0 && _socket) {
|
||||||
|
_socket.disconnect();
|
||||||
|
_socket = null;
|
||||||
|
_refCount = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [handleConnect, handleDisconnect, handleStats, handleSlaBreach]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useXosSocket } from "@/hooks/use-xos-socket";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
||||||
import {
|
import {
|
||||||
|
|
@ -71,6 +72,7 @@ export default function XosSupervisor() {
|
||||||
const [activeTab, setActiveTab] = useState("overview");
|
const [activeTab, setActiveTab] = useState("overview");
|
||||||
const [queueFilter, setQueueFilter] = useState("all");
|
const [queueFilter, setQueueFilter] = useState("all");
|
||||||
const [autoRefresh] = useState(true);
|
const [autoRefresh] = useState(true);
|
||||||
|
const { connected: socketConnected, supervisorStats: liveStats } = useXosSocket();
|
||||||
|
|
||||||
const refetchInterval = autoRefresh ? 30000 : false;
|
const refetchInterval = autoRefresh ? 30000 : false;
|
||||||
|
|
||||||
|
|
@ -143,9 +145,13 @@ export default function XosSupervisor() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer real-time socket stats over polling data when available
|
||||||
const conv = overview?.conversations;
|
const conv = overview?.conversations;
|
||||||
const tick = overview?.tickets;
|
const tick = overview?.tickets;
|
||||||
const totalActive = (conv?.open || 0) + (conv?.pending || 0);
|
const totalActive = liveStats?.openConversations ?? ((conv?.open || 0) + (conv?.pending || 0));
|
||||||
|
const resolvedToday = liveStats?.resolvedToday ?? conv?.resolved_today ?? 0;
|
||||||
|
const urgentTickets = liveStats?.urgentTickets ?? tick?.urgent_open ?? 0;
|
||||||
|
const agentsOnline = liveStats?.agentsOnline ?? overview?.agents_active?.length ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserFrame>
|
<BrowserFrame>
|
||||||
|
|
@ -167,7 +173,10 @@ export default function XosSupervisor() {
|
||||||
<h1 className="text-xl font-bold text-slate-800">Monitor de Supervisor</h1>
|
<h1 className="text-xl font-bold text-slate-800">Monitor de Supervisor</h1>
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500">
|
||||||
Atualizado às {overview ? new Date(overview.generated_at).toLocaleTimeString("pt-BR") : "—"}
|
Atualizado às {overview ? new Date(overview.generated_at).toLocaleTimeString("pt-BR") : "—"}
|
||||||
{autoRefresh && <span className="ml-2 text-emerald-600">• ao vivo</span>}
|
{socketConnected
|
||||||
|
? <span className="ml-2 text-emerald-600">● tempo real</span>
|
||||||
|
: autoRefresh && <span className="ml-2 text-yellow-600">○ polling 30s</span>
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -205,6 +214,7 @@ export default function XosSupervisor() {
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-center gap-2 text-sm text-blue-100">
|
<div className="mt-2 flex items-center gap-2 text-sm text-blue-100">
|
||||||
<span>{conv?.unassigned || 0} sem agente</span>
|
<span>{conv?.unassigned || 0} sem agente</span>
|
||||||
|
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{conv?.pending || 0} pendentes</span>
|
<span>{conv?.pending || 0} pendentes</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -216,7 +226,7 @@ export default function XosSupervisor() {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-emerald-100 text-sm">Resolvidos Hoje</p>
|
<p className="text-emerald-100 text-sm">Resolvidos Hoje</p>
|
||||||
<p className="text-3xl font-bold">{conv?.resolved_today || 0}</p>
|
<p className="text-3xl font-bold">{resolvedToday}</p>
|
||||||
</div>
|
</div>
|
||||||
<CheckCircle2 className="h-10 w-10 text-emerald-200" />
|
<CheckCircle2 className="h-10 w-10 text-emerald-200" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -231,7 +241,7 @@ export default function XosSupervisor() {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-orange-100 text-sm">Tickets Urgentes</p>
|
<p className="text-orange-100 text-sm">Tickets Urgentes</p>
|
||||||
<p className="text-3xl font-bold">{tick?.urgent_open || 0}</p>
|
<p className="text-3xl font-bold">{urgentTickets}</p>
|
||||||
</div>
|
</div>
|
||||||
<Ticket className="h-10 w-10 text-orange-200" />
|
<Ticket className="h-10 w-10 text-orange-200" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -252,7 +262,7 @@ export default function XosSupervisor() {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-violet-100 text-sm">Agentes Ativos</p>
|
<p className="text-violet-100 text-sm">Agentes Ativos</p>
|
||||||
<p className="text-3xl font-bold">{overview?.agents_active?.length || 0}</p>
|
<p className="text-3xl font-bold">{agentsOnline}</p>
|
||||||
</div>
|
</div>
|
||||||
<Users className="h-10 w-10 text-violet-200" />
|
<Users className="h-10 w-10 text-violet-200" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,8 @@ import pipelineRoutes from "./blackboard/pipelineRoutes";
|
||||||
import { startAllAgents } from "./blackboard/agents";
|
import { startAllAgents } from "./blackboard/agents";
|
||||||
import { loadModuleRoutes } from "./modules/loader";
|
import { loadModuleRoutes } from "./modules/loader";
|
||||||
import graphRoutes from "./graph/routes";
|
import graphRoutes from "./graph/routes";
|
||||||
|
import { initSocketIO } from "./socket-io";
|
||||||
|
import { startXosScheduler } from "./xos/scheduler";
|
||||||
|
|
||||||
export async function registerRoutes(
|
export async function registerRoutes(
|
||||||
httpServer: Server,
|
httpServer: Server,
|
||||||
|
|
@ -80,6 +82,8 @@ export async function registerRoutes(
|
||||||
registerChatRoutes(app);
|
registerChatRoutes(app);
|
||||||
registerSoeRoutes(app);
|
registerSoeRoutes(app);
|
||||||
registerInternalChatRoutes(app);
|
registerInternalChatRoutes(app);
|
||||||
|
// Initialize shared Socket.IO singleton (must be before other socket setups)
|
||||||
|
initSocketIO(httpServer);
|
||||||
setupChatSocket(httpServer);
|
setupChatSocket(httpServer);
|
||||||
setupCommunitySocket(httpServer);
|
setupCommunitySocket(httpServer);
|
||||||
registerWhatsappRoutes(app);
|
registerWhatsappRoutes(app);
|
||||||
|
|
@ -134,6 +138,9 @@ export async function registerRoutes(
|
||||||
// Iniciar os 6 agentes do Blackboard
|
// Iniciar os 6 agentes do Blackboard
|
||||||
startAllAgents();
|
startAllAgents();
|
||||||
|
|
||||||
|
// XOS Scheduler: SLA breach checker + supervisor stats broadcaster
|
||||||
|
startXosScheduler();
|
||||||
|
|
||||||
// Central de Protocolos (MCP, A2A, AP2, UCP)
|
// Central de Protocolos (MCP, A2A, AP2, UCP)
|
||||||
app.use("/api", protocolsRoutes);
|
app.use("/api", protocolsRoutes);
|
||||||
registerAgentCard(app); // Agent Card na raiz (/.well-known/agent.json)
|
registerAgentCard(app); // Agent Card na raiz (/.well-known/agent.json)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
/**
|
||||||
|
* Singleton Socket.IO — instância compartilhada entre todos os módulos.
|
||||||
|
* Inicializado em routes.ts via initSocketIO(httpServer).
|
||||||
|
* Qualquer módulo pode importar getIO() para emitir eventos.
|
||||||
|
*/
|
||||||
|
import { Server as HttpServer } from "http";
|
||||||
|
import { Server as SocketServer, Socket } from "socket.io";
|
||||||
|
|
||||||
|
let _io: SocketServer | null = null;
|
||||||
|
|
||||||
|
export function initSocketIO(httpServer: HttpServer): SocketServer {
|
||||||
|
if (_io) return _io;
|
||||||
|
_io = new SocketServer(httpServer, {
|
||||||
|
path: "/socket.io",
|
||||||
|
cors: { origin: "*", methods: ["GET", "POST"] },
|
||||||
|
});
|
||||||
|
return _io;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIO(): SocketServer {
|
||||||
|
if (!_io) throw new Error("[socket-io] Not initialized. Call initSocketIO first.");
|
||||||
|
return _io;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emite um evento XOS para todos os clientes conectados (não bloqueia). */
|
||||||
|
export function broadcastXos(event: string, data: unknown) {
|
||||||
|
try {
|
||||||
|
getIO().emit(`xos:${event}`, data);
|
||||||
|
} catch {
|
||||||
|
// Socket ainda não iniciado ou sem clientes — ignorar silenciosamente
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,8 @@ interface AutoReplyConfig {
|
||||||
outsideHoursMessage: string;
|
outsideHoursMessage: string;
|
||||||
aiEnabled: boolean;
|
aiEnabled: boolean;
|
||||||
maxAutoRepliesPerContact: number;
|
maxAutoRepliesPerContact: number;
|
||||||
|
/** Optional: link to an XOS queue for schedule/out-of-hours config */
|
||||||
|
xosQueueId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WhatsAppSession {
|
interface WhatsAppSession {
|
||||||
|
|
@ -130,6 +132,31 @@ Nome do cliente: ${contactName}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Check XOS queue schedule. Returns { isOpen, outOfHoursMessage }. */
|
||||||
|
private async checkXosQueueIsOpen(queueId: number): Promise<{ isOpen: boolean; outOfHoursMessage: string | null }> {
|
||||||
|
try {
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
SELECT schedules, out_of_hours_message FROM xos_queues WHERE id = ${queueId}
|
||||||
|
`);
|
||||||
|
const queue = ((result as any).rows ?? [])[0];
|
||||||
|
if (!queue) return { isOpen: true, outOfHoursMessage: null };
|
||||||
|
|
||||||
|
const schedules: Array<{ dayOfWeek: number; startTime: string; endTime: string; enabled: boolean }> = queue.schedules || [];
|
||||||
|
if (!schedules.length) return { isOpen: true, outOfHoursMessage: null };
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const dayOfWeek = now.getDay(); // 0=Sun, 6=Sat
|
||||||
|
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||||
|
const todaySchedule = schedules.find((s) => s.dayOfWeek === dayOfWeek && s.enabled !== false);
|
||||||
|
|
||||||
|
if (!todaySchedule) return { isOpen: false, outOfHoursMessage: queue.out_of_hours_message };
|
||||||
|
const isOpen = currentTime >= todaySchedule.startTime && currentTime < todaySchedule.endTime;
|
||||||
|
return { isOpen, outOfHoursMessage: isOpen ? null : queue.out_of_hours_message };
|
||||||
|
} catch {
|
||||||
|
return { isOpen: true, outOfHoursMessage: null }; // fail-open: don't block messages if DB query fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async processAutoReply(msg: IncomingMessage, contact: typeof whatsappContacts.$inferSelect): Promise<void> {
|
private async processAutoReply(msg: IncomingMessage, contact: typeof whatsappContacts.$inferSelect): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const config = this.getAutoReplyConfig(msg.userId);
|
const config = this.getAutoReplyConfig(msg.userId);
|
||||||
|
|
@ -142,13 +169,24 @@ Nome do cliente: ${contactName}`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check business hours — prefer XOS queue schedule when configured
|
||||||
|
let isBusinessHours: boolean;
|
||||||
|
let outsideHoursReply: string;
|
||||||
|
|
||||||
|
if (config.xosQueueId) {
|
||||||
|
const { isOpen, outOfHoursMessage } = await this.checkXosQueueIsOpen(config.xosQueueId);
|
||||||
|
isBusinessHours = isOpen;
|
||||||
|
outsideHoursReply = outOfHoursMessage || config.outsideHoursMessage;
|
||||||
|
} else {
|
||||||
const currentHour = new Date().getHours();
|
const currentHour = new Date().getHours();
|
||||||
const isBusinessHours = currentHour >= config.businessHours.start && currentHour < config.businessHours.end;
|
isBusinessHours = currentHour >= config.businessHours.start && currentHour < config.businessHours.end;
|
||||||
|
outsideHoursReply = config.outsideHoursMessage;
|
||||||
|
}
|
||||||
|
|
||||||
let replyText: string;
|
let replyText: string;
|
||||||
|
|
||||||
if (!isBusinessHours) {
|
if (!isBusinessHours) {
|
||||||
replyText = config.outsideHoursMessage;
|
replyText = outsideHoursReply;
|
||||||
} else if (currentCount === 0) {
|
} else if (currentCount === 0) {
|
||||||
replyText = config.welcomeMessage;
|
replyText = config.welcomeMessage;
|
||||||
} else if (config.aiEnabled) {
|
} else if (config.aiEnabled) {
|
||||||
|
|
|
||||||
|
|
@ -1646,4 +1646,143 @@ export async function fireCrmAutomations(eventType: string, payload: Record<stri
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Campaign Segmentation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/xos/campaigns/segment
|
||||||
|
* Returns XOS contacts matching a segmentation query for campaign targeting.
|
||||||
|
* Body: { tenantId?, filters: { type?, leadStatus?, source?, tags?, city?, state?,
|
||||||
|
* hasPhone?, hasWhatsapp?, hasEmail?, leadScoreMin?, leadScoreMax?, assignedTo? },
|
||||||
|
* limit?, offset? }
|
||||||
|
*/
|
||||||
|
router.post("/campaigns/segment", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { tenantId, filters = {}, limit = 500, offset = 0 } = req.body;
|
||||||
|
const safeLimit = Math.min(Number(limit) || 500, 5000);
|
||||||
|
const safeOffset = Math.max(Number(offset) || 0, 0);
|
||||||
|
|
||||||
|
// Build SQL fragments dynamically
|
||||||
|
const clauses: ReturnType<typeof sql>[] = [sql`1=1`];
|
||||||
|
|
||||||
|
if (tenantId) clauses.push(sql`c.tenant_id = ${tenantId}`);
|
||||||
|
if (filters.type) clauses.push(sql`c.type = ${filters.type}`);
|
||||||
|
if (filters.city) clauses.push(sql`c.city ILIKE ${"%" + filters.city + "%"}`);
|
||||||
|
if (filters.state) clauses.push(sql`c.state ILIKE ${"%" + filters.state + "%"}`);
|
||||||
|
if (filters.assignedTo) clauses.push(sql`c.assigned_to = ${filters.assignedTo}`);
|
||||||
|
if (filters.hasPhone === true) clauses.push(sql`(c.phone IS NOT NULL AND c.phone != '')`);
|
||||||
|
if (filters.hasPhone === false) clauses.push(sql`(c.phone IS NULL OR c.phone = '')`);
|
||||||
|
if (filters.hasWhatsapp === true) clauses.push(sql`(c.whatsapp IS NOT NULL AND c.whatsapp != '')`);
|
||||||
|
if (filters.hasWhatsapp === false) clauses.push(sql`(c.whatsapp IS NULL OR c.whatsapp = '')`);
|
||||||
|
if (filters.hasEmail === true) clauses.push(sql`(c.email IS NOT NULL AND c.email != '')`);
|
||||||
|
if (filters.hasEmail === false) clauses.push(sql`(c.email IS NULL OR c.email = '')`);
|
||||||
|
if (typeof filters.leadScoreMin === "number") clauses.push(sql`c.lead_score >= ${filters.leadScoreMin}`);
|
||||||
|
if (typeof filters.leadScoreMax === "number") clauses.push(sql`c.lead_score <= ${filters.leadScoreMax}`);
|
||||||
|
if (Array.isArray(filters.leadStatus) && filters.leadStatus.length > 0) {
|
||||||
|
clauses.push(sql`c.lead_status = ANY(${filters.leadStatus})`);
|
||||||
|
}
|
||||||
|
if (Array.isArray(filters.source) && filters.source.length > 0) {
|
||||||
|
clauses.push(sql`c.source = ANY(${filters.source})`);
|
||||||
|
}
|
||||||
|
if (Array.isArray(filters.tags) && filters.tags.length > 0) {
|
||||||
|
clauses.push(sql`c.tags @> ${filters.tags}::text[]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSql = sql.join(clauses, sql` AND `);
|
||||||
|
|
||||||
|
const countResult = await db.execute(sql`SELECT COUNT(*) AS total FROM xos_contacts c WHERE ${whereSql}`);
|
||||||
|
const total = Number(((countResult as any).rows?.[0])?.total ?? 0);
|
||||||
|
|
||||||
|
const dataResult = await db.execute(sql`
|
||||||
|
SELECT c.id, c.name, c.email, c.phone, c.whatsapp, c.type, c.lead_status,
|
||||||
|
c.lead_score, c.source, c.tags, c.city, c.state, c.company,
|
||||||
|
c.assigned_to, c.last_contact_at
|
||||||
|
FROM xos_contacts c
|
||||||
|
WHERE ${whereSql}
|
||||||
|
ORDER BY c.name ASC
|
||||||
|
LIMIT ${safeLimit} OFFSET ${safeOffset}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const contacts = (dataResult as any).rows ?? [];
|
||||||
|
res.json({ total, limit: safeLimit, offset: safeOffset, count: contacts.length, contacts });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Campaign segment error:", error);
|
||||||
|
res.status(500).json({ error: "Failed to query campaign segment" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/xos/campaigns — list campaigns for tenant
|
||||||
|
*/
|
||||||
|
router.get("/campaigns", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = (req as any).user?.tenantId;
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
SELECT id, name, description, type, status, segment_query, subject,
|
||||||
|
scheduled_at, started_at, completed_at, stats, created_at, updated_at
|
||||||
|
FROM xos_campaigns
|
||||||
|
${tenantId ? sql`WHERE tenant_id = ${tenantId}` : sql``}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`);
|
||||||
|
res.json((result as any).rows ?? []);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("List campaigns error:", error);
|
||||||
|
res.status(500).json({ error: "Failed to list campaigns" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/xos/campaigns — create campaign
|
||||||
|
*/
|
||||||
|
router.post("/campaigns", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const tenantId = (req as any).user?.tenantId;
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
const { name, description, type, segmentQuery, content, subject, scheduledAt } = req.body;
|
||||||
|
if (!name || !type) return res.status(400).json({ error: "name and type are required" });
|
||||||
|
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
INSERT INTO xos_campaigns (tenant_id, name, description, type, status, segment_query, content, subject, scheduled_at, created_by)
|
||||||
|
VALUES (
|
||||||
|
${tenantId || null}, ${name}, ${description || null}, ${type}, 'draft',
|
||||||
|
${segmentQuery ? JSON.stringify(segmentQuery) : null},
|
||||||
|
${content || null}, ${subject || null}, ${scheduledAt || null}, ${userId || null}
|
||||||
|
)
|
||||||
|
RETURNING *
|
||||||
|
`);
|
||||||
|
res.status(201).json(((result as any).rows ?? [])[0]);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Create campaign error:", error);
|
||||||
|
res.status(500).json({ error: "Failed to create campaign" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/xos/campaigns/:id — update campaign
|
||||||
|
*/
|
||||||
|
router.patch("/campaigns/:id", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const { status, name, description, content, subject, scheduledAt, segmentQuery } = req.body;
|
||||||
|
await db.execute(sql`
|
||||||
|
UPDATE xos_campaigns SET
|
||||||
|
status = COALESCE(${status || null}, status),
|
||||||
|
name = COALESCE(${name || null}, name),
|
||||||
|
description = COALESCE(${description || null}, description),
|
||||||
|
content = COALESCE(${content || null}, content),
|
||||||
|
subject = COALESCE(${subject || null}, subject),
|
||||||
|
scheduled_at = COALESCE(${scheduledAt || null}, scheduled_at),
|
||||||
|
segment_query = COALESCE(${segmentQuery ? JSON.stringify(segmentQuery) : null}::jsonb, segment_query),
|
||||||
|
started_at = CASE WHEN ${status || null} = 'running' AND started_at IS NULL THEN NOW() ELSE started_at END,
|
||||||
|
completed_at = CASE WHEN ${status || null} IN ('completed','cancelled') AND completed_at IS NULL THEN NOW() ELSE completed_at END,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ${id}
|
||||||
|
`);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Update campaign error:", error);
|
||||||
|
res.status(500).json({ error: "Failed to update campaign" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
/**
|
||||||
|
* XOS Scheduler
|
||||||
|
* - Every 5 min: check SLA breaches and emit events
|
||||||
|
* - Every 30 sec: broadcast live supervisor stats via Socket.IO
|
||||||
|
*/
|
||||||
|
import { db } from "../db";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { broadcastXos } from "../socket-io";
|
||||||
|
|
||||||
|
const SLA_CHECK_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
const STATS_BROADCAST_INTERVAL_MS = 30 * 1000; // 30 seconds
|
||||||
|
|
||||||
|
/** Mark overdue protocols as SLA-breached and emit event per tenant */
|
||||||
|
async function checkSlaBreaches() {
|
||||||
|
try {
|
||||||
|
// Mark protocols where sla_deadline has passed and still open/unbreached
|
||||||
|
const breached = await db.execute(sql`
|
||||||
|
UPDATE xos_protocols
|
||||||
|
SET sla_breach = true, updated_at = NOW()
|
||||||
|
WHERE status = 'open'
|
||||||
|
AND sla_deadline IS NOT NULL
|
||||||
|
AND sla_deadline < NOW()
|
||||||
|
AND sla_breach = false
|
||||||
|
RETURNING id, tenant_id, protocol_number, contact_id, queue_id, assigned_to
|
||||||
|
`);
|
||||||
|
|
||||||
|
const rows = (breached as any).rows ?? [];
|
||||||
|
for (const row of rows) {
|
||||||
|
broadcastXos("sla.breach", {
|
||||||
|
protocolId: row.id,
|
||||||
|
tenantId: row.tenant_id,
|
||||||
|
protocolNumber: row.protocol_number,
|
||||||
|
contactId: row.contact_id,
|
||||||
|
queueId: row.queue_id,
|
||||||
|
assignedTo: row.assigned_to,
|
||||||
|
breachedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forward to automation engine (non-blocking)
|
||||||
|
try {
|
||||||
|
const engineHost = process.env.AUTOMATION_ENGINE_HOST || "localhost";
|
||||||
|
const enginePort = process.env.AUTOMATION_ENGINE_PORT || "8005";
|
||||||
|
await fetch(`http://${engineHost}:${enginePort}/xos/trigger`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
event_type: "crm.sla.breached",
|
||||||
|
payload: { protocol_id: row.id, protocol_number: row.protocol_number },
|
||||||
|
tenant_id: row.tenant_id,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Non-blocking — engine may be offline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
console.log(`[xos:scheduler] SLA check: ${rows.length} breach(es) detected`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[xos:scheduler] SLA check error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broadcast aggregate supervisor stats to all connected clients */
|
||||||
|
async function broadcastSupervisorStats() {
|
||||||
|
try {
|
||||||
|
const [convsResult, ticketsResult, agentsResult] = await Promise.all([
|
||||||
|
db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE status = 'open') AS open_conversations,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'resolved' AND updated_at > NOW() - INTERVAL '24h') AS resolved_today
|
||||||
|
FROM xos_conversations
|
||||||
|
`),
|
||||||
|
db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE status IN ('open','in_progress') AND priority = 'urgent') AS urgent_open
|
||||||
|
FROM xos_tickets
|
||||||
|
`),
|
||||||
|
db.execute(sql`
|
||||||
|
SELECT COUNT(*) FILTER (WHERE is_online = true) AS agents_online
|
||||||
|
FROM xos_agents
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const c = ((convsResult as any).rows ?? [])[0] ?? {};
|
||||||
|
const t = ((ticketsResult as any).rows ?? [])[0] ?? {};
|
||||||
|
const a = ((agentsResult as any).rows ?? [])[0] ?? {};
|
||||||
|
|
||||||
|
broadcastXos("supervisor.stats", {
|
||||||
|
openConversations: Number(c.open_conversations ?? 0),
|
||||||
|
resolvedToday: Number(c.resolved_today ?? 0),
|
||||||
|
urgentTickets: Number(t.urgent_open ?? 0),
|
||||||
|
agentsOnline: Number(a.agents_online ?? 0),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Suppress — tables may not exist in dev
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
console.debug("[xos:scheduler] stats broadcast skipped:", (err as any)?.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startXosScheduler() {
|
||||||
|
// Stagger initial runs slightly to avoid startup congestion
|
||||||
|
setTimeout(() => {
|
||||||
|
checkSlaBreaches();
|
||||||
|
setInterval(checkSlaBreaches, SLA_CHECK_INTERVAL_MS);
|
||||||
|
}, 15_000); // first SLA check 15s after boot
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
broadcastSupervisorStats();
|
||||||
|
setInterval(broadcastSupervisorStats, STATS_BROADCAST_INTERVAL_MS);
|
||||||
|
}, 5_000); // first broadcast 5s after boot
|
||||||
|
|
||||||
|
console.log("[xos:scheduler] Started — SLA checks every 5min, stats every 30s");
|
||||||
|
}
|
||||||
|
|
@ -7145,6 +7145,8 @@ export const xosSlaPolicies = pgTable("xos_sla_policies", {
|
||||||
export const insertXosSlaPolicySchema = createInsertSchema(xosSlaPolicies).omit({ id: true, createdAt: true, updatedAt: true });
|
export const insertXosSlaPolicySchema = createInsertSchema(xosSlaPolicies).omit({ id: true, createdAt: true, updatedAt: true });
|
||||||
export type XosSlaPolicy = typeof xosSlaPolicies.$inferSelect;
|
export type XosSlaPolicy = typeof xosSlaPolicies.$inferSelect;
|
||||||
export type InsertXosSlaPolicy = z.infer<typeof insertXosSlaPolicySchema>;
|
export type InsertXosSlaPolicy = z.infer<typeof insertXosSlaPolicySchema>;
|
||||||
|
|
||||||
|
export const xosDevPipelines = pgTable("xos_dev_pipelines", {
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
correlationId: text("correlation_id").notNull().default(sql`gen_random_uuid()`),
|
correlationId: text("correlation_id").notNull().default(sql`gen_random_uuid()`),
|
||||||
prompt: text("prompt").notNull(),
|
prompt: text("prompt").notNull(),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue