146 lines
4.0 KiB
TypeScript
146 lines
4.0 KiB
TypeScript
import { useEffect, useRef, useCallback } from 'react';
|
|
import { useStore } from '@/store/useStore';
|
|
import { WS_URL } from '@/lib/api';
|
|
import type { QDScoreUpdate, VaultOpenedEvent } from '@/types';
|
|
|
|
const SENTINEL_WS_ROOT = `${WS_URL}/api/sentinel/ws`;
|
|
|
|
type WsEventType =
|
|
| 'WS_ASSET_OPENED'
|
|
| 'QD_UPDATED'
|
|
| 'LEAD_TAGGED'
|
|
| 'system'
|
|
| 'ack';
|
|
|
|
interface WsMessage {
|
|
type: WsEventType;
|
|
data?: Record<string, unknown>;
|
|
timestamp?: string;
|
|
}
|
|
|
|
interface UseVelocitySocketOptions {
|
|
channel?: 'notifications' | 'perception';
|
|
onConnect?: () => void;
|
|
onDisconnect?: () => void;
|
|
onMessage?: (msg: WsMessage) => void;
|
|
}
|
|
|
|
export function useVelocitySocket(options: UseVelocitySocketOptions = {}) {
|
|
const { channel = 'notifications', onConnect, onDisconnect, onMessage } = options;
|
|
const { addNotification } = useStore();
|
|
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const retryCountRef = useRef(0);
|
|
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const pendingBufferRef = useRef<string[]>([]);
|
|
const isMountedRef = useRef(true);
|
|
|
|
const handleMessage = useCallback(
|
|
(event: MessageEvent) => {
|
|
let msg: WsMessage;
|
|
try {
|
|
msg = JSON.parse(event.data as string) as WsMessage;
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
onMessage?.(msg);
|
|
|
|
switch (msg.type) {
|
|
case 'WS_ASSET_OPENED': {
|
|
const d = msg.data as Partial<VaultOpenedEvent>;
|
|
addNotification({
|
|
type: 'velocity_link_opened',
|
|
title: 'Velocity Link Opened',
|
|
body: `${d.lead_name ?? 'A prospect'} just opened ${d.asset_name ?? 'your asset'}.`,
|
|
leadId: d.lead_id,
|
|
});
|
|
break;
|
|
}
|
|
case 'QD_UPDATED': {
|
|
const d = msg.data as Partial<QDScoreUpdate>;
|
|
if ((d.qd_score ?? 0) >= 75) {
|
|
addNotification({
|
|
type: 'qd_spike',
|
|
title: 'QD Score Spike',
|
|
body: `QD Score jumped to ${d.qd_score}. ${d.reasoning ?? ''}`.trim(),
|
|
leadId: d.lead_id,
|
|
qdScore: d.qd_score,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case 'LEAD_TAGGED': {
|
|
const d = msg.data as { lead_id?: string; lead_name?: string; tags?: string[] };
|
|
if (d.tags?.length) {
|
|
addNotification({
|
|
type: 'lead_tagged',
|
|
title: 'Lead Tagged',
|
|
body: `${d.lead_name ?? 'Lead'} tagged as ${d.tags.join(', ')}.`,
|
|
leadId: d.lead_id,
|
|
tags: d.tags,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
},
|
|
[addNotification, onMessage],
|
|
);
|
|
|
|
const connect = useCallback(() => {
|
|
if (!isMountedRef.current) return;
|
|
|
|
const url = `${SENTINEL_WS_ROOT}/${channel}`;
|
|
const ws = new WebSocket(url);
|
|
wsRef.current = ws;
|
|
|
|
ws.onopen = () => {
|
|
retryCountRef.current = 0;
|
|
pendingBufferRef.current.forEach((msg) => ws.send(msg));
|
|
pendingBufferRef.current = [];
|
|
onConnect?.();
|
|
};
|
|
|
|
ws.onmessage = handleMessage;
|
|
|
|
ws.onclose = () => {
|
|
onDisconnect?.();
|
|
if (!isMountedRef.current) return;
|
|
if (retryCountRef.current >= 5) return;
|
|
const delay = Math.min(1000 * 2 ** retryCountRef.current, 30_000);
|
|
retryCountRef.current += 1;
|
|
retryTimerRef.current = setTimeout(connect, delay);
|
|
};
|
|
|
|
ws.onerror = () => ws.close();
|
|
}, [channel, handleMessage, onConnect, onDisconnect]);
|
|
|
|
useEffect(() => {
|
|
isMountedRef.current = true;
|
|
connect();
|
|
|
|
return () => {
|
|
isMountedRef.current = false;
|
|
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
|
wsRef.current?.close();
|
|
};
|
|
}, [connect]);
|
|
|
|
const sendPacket = useCallback((payload: unknown) => {
|
|
const str = JSON.stringify(payload);
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
wsRef.current.send(str);
|
|
} else {
|
|
pendingBufferRef.current.push(str);
|
|
if (pendingBufferRef.current.length > 100) {
|
|
pendingBufferRef.current.shift();
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
return { sendPacket };
|
|
}
|