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>
333 lines
10 KiB
TypeScript
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,
|
|
};
|
|
}
|