Files
smoothschedule/legacy_reference/frontend/src/hooks/useAppointmentWebSocket.ts
poduck 2e111364a2 Initial commit: SmoothSchedule multi-tenant scheduling platform
This commit includes:
- Django backend with multi-tenancy (django-tenants)
- React + TypeScript frontend with Vite
- Platform administration API with role-based access control
- Authentication system with token-based auth
- Quick login dev tools for testing different user roles
- CORS and CSRF configuration for local development
- Docker development environment setup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 01:43:20 -05:00

333 lines
10 KiB
TypeScript

/**
* WebSocket hook for real-time appointment updates.
* Connects to the backend WebSocket and updates React Query cache.
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { getCookie } from '../utils/cookies';
import { getSubdomain } from '../api/config';
import { Appointment } from '../types';
interface WebSocketMessage {
type: 'connection_established' | 'appointment_created' | 'appointment_updated' | 'appointment_deleted' | 'pong';
appointment?: {
id: string;
business_id: string;
service_id: string;
resource_id: string | null;
customer_id: string;
customer_name: string;
start_time: string;
end_time: string;
duration_minutes: number;
status: string;
notes: string;
};
appointment_id?: string;
message?: string;
}
interface UseAppointmentWebSocketOptions {
enabled?: boolean;
onConnected?: () => void;
onDisconnected?: () => void;
onError?: (error: Event) => void;
}
/**
* Transform backend appointment format to frontend format
*/
function transformAppointment(data: WebSocketMessage['appointment']): Appointment | null {
if (!data) return null;
return {
id: data.id,
resourceId: data.resource_id,
customerId: data.customer_id,
customerName: data.customer_name,
serviceId: data.service_id,
startTime: new Date(data.start_time),
durationMinutes: data.duration_minutes,
status: data.status as Appointment['status'],
notes: data.notes,
};
}
/**
* Hook for real-time appointment updates via WebSocket.
* Handles React StrictMode's double-effect invocation gracefully.
*/
export function useAppointmentWebSocket(options: UseAppointmentWebSocketOptions = {}) {
const { enabled = true, onConnected, onDisconnected, onError } = options;
const queryClient = useQueryClient();
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
const isCleaningUpRef = useRef(false);
const maxReconnectAttempts = 5;
const [isConnected, setIsConnected] = useState(false);
// Store callbacks in refs to avoid effect re-runs
const onConnectedRef = useRef(onConnected);
const onDisconnectedRef = useRef(onDisconnected);
const onErrorRef = useRef(onError);
useEffect(() => {
onConnectedRef.current = onConnected;
onDisconnectedRef.current = onDisconnected;
onErrorRef.current = onError;
}, [onConnected, onDisconnected, onError]);
// Get WebSocket URL - not a callback to avoid recreating
const getWebSocketUrl = () => {
const token = getCookie('access_token');
const subdomain = getSubdomain();
if (!token || !subdomain) {
return null;
}
// Determine WebSocket host - use api subdomain for WebSocket
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = `api.lvh.me:8000`; // In production, this would come from config
return `${wsProtocol}//${wsHost}/ws/appointments/?token=${token}&subdomain=${subdomain}`;
};
const updateQueryCache = useCallback((message: WebSocketMessage) => {
const queryCache = queryClient.getQueryCache();
const appointmentQueries = queryCache.findAll({ queryKey: ['appointments'] });
appointmentQueries.forEach((query) => {
queryClient.setQueryData<Appointment[]>(query.queryKey, (old) => {
if (!old) return old;
switch (message.type) {
case 'appointment_created': {
const newAppointment = transformAppointment(message.appointment);
if (!newAppointment) return old;
// Check if appointment already exists (avoid duplicates)
if (old.some(apt => apt.id === newAppointment.id)) {
return old;
}
return [...old, newAppointment];
}
case 'appointment_updated': {
const updatedAppointment = transformAppointment(message.appointment);
if (!updatedAppointment) return old;
return old.map(apt =>
apt.id === updatedAppointment.id ? updatedAppointment : apt
);
}
case 'appointment_deleted': {
if (!message.appointment_id) return old;
return old.filter(apt => apt.id !== message.appointment_id);
}
default:
return old;
}
});
});
}, [queryClient]);
// Main effect to manage WebSocket connection
// Only depends on `enabled` - other values are read from refs or called as functions
useEffect(() => {
if (!enabled) {
return;
}
// Reset cleanup flag at start of effect
isCleaningUpRef.current = false;
// Track the current effect's abort controller to handle StrictMode
let effectAborted = false;
const connect = () => {
// Don't connect if effect was aborted or we're cleaning up
if (effectAborted || isCleaningUpRef.current) {
return;
}
const url = getWebSocketUrl();
if (!url) {
console.log('WebSocket: Missing token or subdomain, skipping connection');
return;
}
// Close existing connection if any
if (wsRef.current && wsRef.current.readyState !== WebSocket.CLOSED) {
wsRef.current.close();
}
console.log('WebSocket: Connecting to', url.replace(/token=[^&]+/, 'token=***'));
const ws = new WebSocket(url);
ws.onopen = () => {
// Don't process if effect was aborted or cleaning up
if (effectAborted || isCleaningUpRef.current) {
ws.close();
return;
}
console.log('WebSocket: Connected');
reconnectAttemptsRef.current = 0;
setIsConnected(true);
onConnectedRef.current?.();
// Start ping interval to keep connection alive
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
}
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN && !effectAborted) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000); // Ping every 30 seconds
};
ws.onmessage = (event) => {
// Ignore messages if effect was aborted
if (effectAborted) return;
try {
const message: WebSocketMessage = JSON.parse(event.data);
switch (message.type) {
case 'connection_established':
console.log('WebSocket: Connection confirmed -', message.message);
break;
case 'pong':
// Heartbeat response, ignore
break;
case 'appointment_created':
case 'appointment_updated':
case 'appointment_deleted':
console.log('WebSocket: Received', message.type);
updateQueryCache(message);
break;
default:
console.log('WebSocket: Unknown message type', message);
}
} catch (err) {
console.error('WebSocket: Failed to parse message', err);
}
};
ws.onerror = (error) => {
// Only log error if not aborted (StrictMode cleanup causes expected errors)
if (!effectAborted) {
console.error('WebSocket: Error', error);
onErrorRef.current?.(error);
}
};
ws.onclose = (event) => {
// Don't log or handle if effect was aborted (expected during StrictMode)
if (effectAborted) {
return;
}
console.log('WebSocket: Disconnected', event.code, event.reason);
setIsConnected(false);
onDisconnectedRef.current?.();
// Clear ping interval
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Only attempt reconnection if not cleaning up
if (!isCleaningUpRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
console.log(`WebSocket: Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`);
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current++;
connect();
}, delay);
}
};
wsRef.current = ws;
};
connect();
// Cleanup function
return () => {
effectAborted = true;
isCleaningUpRef.current = true;
// Clear reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
// Clear ping interval
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Close WebSocket
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
};
}, [enabled]); // Only re-run when enabled changes
const reconnect = useCallback(() => {
isCleaningUpRef.current = false;
reconnectAttemptsRef.current = 0;
// Close existing connection
if (wsRef.current) {
wsRef.current.close();
}
// Connection will be re-established by the effect when we force re-render
// For now, we'll rely on the onclose handler to trigger reconnection
}, []);
const disconnect = useCallback(() => {
isCleaningUpRef.current = true;
// Clear reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
// Clear ping interval
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Close WebSocket
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
}, []);
return {
isConnected,
reconnect,
disconnect,
};
}