Files
smoothschedule/frontend/src/hooks/useAppointmentWebSocket.ts
poduck 4cd6610f2a Fix double /api/ prefix in API endpoint calls
When VITE_API_URL=/api, axios baseURL is already set to /api. However, all endpoint calls included the /api/ prefix, creating double paths like /api/api/auth/login/.

Removed /api/ prefix from 81 API endpoint calls across 22 files:
- src/api/auth.ts - Fixed login, logout, me, refresh, hijack endpoints
- src/api/client.ts - Fixed token refresh endpoint
- src/api/profile.ts - Fixed all profile, email, password, MFA, sessions endpoints
- src/hooks/*.ts - Fixed all remaining API calls (users, appointments, resources, etc)
- src/pages/*.tsx - Fixed signup and email verification endpoints

This ensures API requests use the correct path: /api/auth/login/ instead of /api/api/auth/login/

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 15:27:57 -05:00

337 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 { getWebSocketUrl } from '../utils/domain';
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;
}
// WebSocket is not yet implemented in the backend - disable for now
const WEBSOCKET_ENABLED = false;
/**
* 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;
// Early return if WebSocket is globally disabled
const effectivelyEnabled = enabled && WEBSOCKET_ENABLED;
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 getWsUrl = () => {
const token = getCookie('access_token');
const subdomain = getSubdomain();
if (!token || !subdomain) {
return null;
}
// Use the getWebSocketUrl utility from domain.ts
return `${getWebSocketUrl()}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 (!effectivelyEnabled) {
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 = getWsUrl();
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);
};
}, [effectivelyEnabled]); // 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,
};
}