feat: Add real-time ticket updates via WebSocket and staff permission control
WebSocket Updates: - Create useTicketWebSocket hook for real-time ticket list updates - Hook invalidates React Query cache when tickets are created/updated - Shows toast notifications for new tickets and comments - Auto-reconnect with exponential backoff Staff Permissions: - Add can_access_tickets() method to User model - Owners and managers always have ticket access - Staff members need explicit can_access_tickets permission - Update Sidebar to conditionally show Tickets menu based on permission - Add can_access_tickets to API user response Backend Updates: - Update ticket signals to broadcast updates to all relevant users - Check ticket access permission in views 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
174
frontend/src/hooks/useTicketWebSocket.ts
Normal file
174
frontend/src/hooks/useTicketWebSocket.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useCurrentUser } from './useAuth';
|
||||
|
||||
interface TicketWebSocketMessage {
|
||||
type: 'new_ticket' | 'ticket_update' | 'ticket_deleted' | 'new_comment' | 'ticket_assigned' | 'ticket_status_changed';
|
||||
ticket_id: number | string;
|
||||
subject?: string;
|
||||
ticket_type?: string;
|
||||
priority?: string;
|
||||
status?: string;
|
||||
creator_name?: string;
|
||||
assignee_name?: string;
|
||||
comment_id?: number | string;
|
||||
author_name?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface UseTicketWebSocketOptions {
|
||||
showToasts?: boolean;
|
||||
onNewTicket?: (data: TicketWebSocketMessage) => void;
|
||||
onTicketUpdate?: (data: TicketWebSocketMessage) => void;
|
||||
onNewComment?: (data: TicketWebSocketMessage) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to manage WebSocket connection for real-time ticket updates.
|
||||
* Automatically invalidates React Query cache when ticket changes occur.
|
||||
*/
|
||||
export const useTicketWebSocket = (options: UseTicketWebSocketOptions = {}) => {
|
||||
const { showToasts = true, onNewTicket, onTicketUpdate, onNewComment } = options;
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttempts = useRef(0);
|
||||
const maxReconnectAttempts = 5;
|
||||
const { data: user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleMessage = useCallback((event: MessageEvent) => {
|
||||
try {
|
||||
const data: TicketWebSocketMessage = JSON.parse(event.data);
|
||||
console.log('Ticket WebSocket message received:', data);
|
||||
|
||||
switch (data.type) {
|
||||
case 'new_ticket':
|
||||
// Invalidate ticket list to refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
||||
if (showToasts) {
|
||||
toast.success(data.message || `New ticket: ${data.subject}`, {
|
||||
duration: 5000,
|
||||
position: 'top-right',
|
||||
});
|
||||
}
|
||||
onNewTicket?.(data);
|
||||
break;
|
||||
|
||||
case 'ticket_update':
|
||||
case 'ticket_assigned':
|
||||
case 'ticket_status_changed':
|
||||
// Invalidate specific ticket and list
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
||||
if (data.ticket_id) {
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets', String(data.ticket_id)] });
|
||||
}
|
||||
if (showToasts && data.type !== 'ticket_update') {
|
||||
toast(data.message || 'Ticket updated', {
|
||||
duration: 4000,
|
||||
position: 'top-right',
|
||||
icon: '🎫',
|
||||
});
|
||||
}
|
||||
onTicketUpdate?.(data);
|
||||
break;
|
||||
|
||||
case 'ticket_deleted':
|
||||
// Invalidate ticket list
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
||||
if (data.ticket_id) {
|
||||
// Remove specific ticket from cache
|
||||
queryClient.removeQueries({ queryKey: ['tickets', String(data.ticket_id)] });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'new_comment':
|
||||
// Invalidate comments for the ticket
|
||||
if (data.ticket_id) {
|
||||
queryClient.invalidateQueries({ queryKey: ['ticketComments', String(data.ticket_id)] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets', String(data.ticket_id)] });
|
||||
}
|
||||
if (showToasts) {
|
||||
toast(data.message || 'New comment on ticket', {
|
||||
duration: 4000,
|
||||
position: 'top-right',
|
||||
icon: '💬',
|
||||
});
|
||||
}
|
||||
onNewComment?.(data);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('Unknown ticket WebSocket message type:', data.type);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing ticket WebSocket message:', error);
|
||||
}
|
||||
}, [queryClient, showToasts, onNewTicket, onTicketUpdate, onNewComment]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!user || !user.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine WebSocket URL - connect to backend on port 8000
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsHost = window.location.hostname + ':8000';
|
||||
const wsUrl = `${protocol}//${wsHost}/ws/tickets/`;
|
||||
|
||||
console.log('Connecting to ticket WebSocket:', wsUrl);
|
||||
|
||||
try {
|
||||
wsRef.current = new WebSocket(wsUrl);
|
||||
|
||||
wsRef.current.onopen = () => {
|
||||
console.log('Ticket WebSocket connected');
|
||||
reconnectAttempts.current = 0; // Reset reconnect attempts on successful connection
|
||||
};
|
||||
|
||||
wsRef.current.onmessage = handleMessage;
|
||||
|
||||
wsRef.current.onclose = (event) => {
|
||||
console.log('Ticket WebSocket disconnected:', event.code, event.reason);
|
||||
|
||||
// Attempt to reconnect with exponential backoff
|
||||
if (user && user.id && reconnectAttempts.current < maxReconnectAttempts) {
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000);
|
||||
reconnectAttempts.current += 1;
|
||||
console.log(`Attempting to reconnect ticket WebSocket in ${delay}ms (attempt ${reconnectAttempts.current})`);
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
connect();
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
|
||||
wsRef.current.onerror = (error) => {
|
||||
console.error('Ticket WebSocket error:', error);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to create ticket WebSocket:', error);
|
||||
}
|
||||
}, [user, handleMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
// Clear reconnect timeout
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
// Close WebSocket
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
// Return connection status for debugging
|
||||
return {
|
||||
isConnected: wsRef.current?.readyState === WebSocket.OPEN,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user