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:
@@ -51,6 +51,8 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
const canViewAdminPages = role === 'owner' || role === 'manager';
|
||||
const canViewManagementPages = role === 'owner' || role === 'manager' || role === 'staff';
|
||||
const canViewSettings = role === 'owner';
|
||||
// Tickets: owners/managers always, staff only with permission
|
||||
const canViewTickets = role === 'owner' || role === 'manager' || (role === 'staff' && user.can_access_tickets);
|
||||
|
||||
const getDashboardLink = () => {
|
||||
if (role === 'resource') return '/';
|
||||
@@ -135,13 +137,16 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
<ClipboardList size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.resources')}</span>}
|
||||
</Link>
|
||||
<Link to="/tickets" className={getNavClass('/tickets')} title={t('nav.tickets')}>
|
||||
<Ticket size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.tickets')}</span>}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canViewTickets && (
|
||||
<Link to="/tickets" className={getNavClass('/tickets')} title={t('nav.tickets')}>
|
||||
<Ticket size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.tickets')}</span>}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{canViewAdminPages && (
|
||||
<>
|
||||
{/* Payments link: always visible for owners, only visible for others if enabled */}
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, User, Clock, AlertCircle, CheckCircle, XCircle, Circle, Loader2 } from 'lucide-react';
|
||||
import { useTickets } from '../hooks/useTickets';
|
||||
import { useTicketWebSocket } from '../hooks/useTicketWebSocket';
|
||||
import { Ticket, TicketStatus } from '../types';
|
||||
import TicketModal from '../components/TicketModal';
|
||||
import { useCurrentUser } from '../hooks/useAuth';
|
||||
@@ -82,6 +83,9 @@ const Tickets: React.FC = () => {
|
||||
const [isTicketModalOpen, setIsTicketModalOpen] = useState(false);
|
||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||||
|
||||
// Enable real-time ticket updates via WebSocket
|
||||
useTicketWebSocket({ showToasts: true });
|
||||
|
||||
// Fetch all tickets (backend will filter based on user role)
|
||||
const { data: tickets = [], isLoading, error } = useTickets();
|
||||
|
||||
|
||||
@@ -2,12 +2,16 @@ import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Ticket as TicketIcon, AlertCircle, CheckCircle2, Clock, Circle, Loader2, XCircle, Plus } from 'lucide-react';
|
||||
import { useTickets } from '../../hooks/useTickets';
|
||||
import { useTicketWebSocket } from '../../hooks/useTicketWebSocket';
|
||||
import { Ticket, TicketStatus } from '../../types';
|
||||
import TicketModal from '../../components/TicketModal';
|
||||
import Portal from '../../components/Portal';
|
||||
|
||||
const PlatformSupport: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Enable real-time ticket updates via WebSocket
|
||||
useTicketWebSocket({ showToasts: true });
|
||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [statusFilter, setStatusFilter] = useState<TicketStatus | 'ALL'>('ALL');
|
||||
|
||||
@@ -88,6 +88,9 @@ export interface User {
|
||||
timezone?: string;
|
||||
locale?: string;
|
||||
notification_preferences?: NotificationPreferences;
|
||||
can_invite_staff?: boolean;
|
||||
can_access_tickets?: boolean;
|
||||
permissions?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export type ResourceType = 'STAFF' | 'ROOM' | 'EQUIPMENT';
|
||||
|
||||
@@ -69,6 +69,7 @@ def current_user_view(request):
|
||||
'business_subdomain': business_subdomain,
|
||||
'permissions': user.permissions,
|
||||
'can_invite_staff': user.can_invite_staff(),
|
||||
'can_access_tickets': user.can_access_tickets(),
|
||||
}
|
||||
|
||||
return Response(user_data, status=status.HTTP_200_OK)
|
||||
@@ -212,6 +213,7 @@ def _get_user_data(user):
|
||||
'business_subdomain': business_subdomain,
|
||||
'permissions': user.permissions,
|
||||
'can_invite_staff': user.can_invite_staff(),
|
||||
'can_access_tickets': user.can_access_tickets(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -143,6 +143,22 @@ class User(AbstractUser):
|
||||
if self.role == self.Role.TENANT_MANAGER:
|
||||
return self.permissions.get('can_invite_staff', False)
|
||||
return False
|
||||
|
||||
def can_access_tickets(self):
|
||||
"""Check if user can access the ticket system"""
|
||||
# Platform users can always access
|
||||
if self.is_platform_user():
|
||||
return True
|
||||
# Owners and managers can always access
|
||||
if self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]:
|
||||
return True
|
||||
# Staff can access if granted permission (default: False)
|
||||
if self.role == self.Role.TENANT_STAFF:
|
||||
return self.permissions.get('can_access_tickets', False)
|
||||
# Customers can create tickets
|
||||
if self.role == self.Role.CUSTOMER:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_accessible_tenants(self):
|
||||
"""
|
||||
|
||||
@@ -190,32 +190,46 @@ def _handle_ticket_creation(ticket):
|
||||
def _handle_ticket_update(ticket):
|
||||
"""Send notifications when a ticket is updated."""
|
||||
try:
|
||||
update_message = {
|
||||
"type": "ticket_update",
|
||||
"ticket_id": ticket.id,
|
||||
"subject": ticket.subject,
|
||||
"status": ticket.status,
|
||||
"priority": ticket.priority,
|
||||
"ticket_type": ticket.ticket_type,
|
||||
"message": f"Ticket #{ticket.id} '{ticket.subject}' updated. Status: {ticket.status}"
|
||||
}
|
||||
|
||||
# For PLATFORM tickets, notify platform support team
|
||||
if ticket.ticket_type == Ticket.TicketType.PLATFORM:
|
||||
platform_team = get_platform_support_team()
|
||||
for member in platform_team:
|
||||
send_websocket_notification(f"user_{member.id}", update_message)
|
||||
|
||||
# For tenant tickets, notify tenant managers and staff with ticket access
|
||||
if ticket.tenant:
|
||||
tenant_managers = get_tenant_managers(ticket.tenant)
|
||||
for manager in tenant_managers:
|
||||
send_websocket_notification(f"user_{manager.id}", update_message)
|
||||
|
||||
# Notify creator (if different from managers already notified)
|
||||
if ticket.creator:
|
||||
send_websocket_notification(f"user_{ticket.creator.id}", update_message)
|
||||
|
||||
# Notify assignee if one exists
|
||||
if not ticket.assignee:
|
||||
return
|
||||
if ticket.assignee:
|
||||
# Create Notification object for the assignee
|
||||
create_notification(
|
||||
recipient=ticket.assignee,
|
||||
actor=ticket.creator,
|
||||
verb=f"Ticket #{ticket.id} '{ticket.subject}' was updated.",
|
||||
action_object=ticket,
|
||||
target=ticket,
|
||||
data={'ticket_id': ticket.id, 'subject': ticket.subject, 'status': ticket.status}
|
||||
)
|
||||
# Send WebSocket to assignee
|
||||
send_websocket_notification(f"user_{ticket.assignee.id}", update_message)
|
||||
|
||||
# Create Notification object for the assignee
|
||||
create_notification(
|
||||
recipient=ticket.assignee,
|
||||
actor=ticket.creator,
|
||||
verb=f"Ticket #{ticket.id} '{ticket.subject}' was updated.",
|
||||
action_object=ticket,
|
||||
target=ticket,
|
||||
data={'ticket_id': ticket.id, 'subject': ticket.subject, 'status': ticket.status}
|
||||
)
|
||||
|
||||
# Send WebSocket message to assignee's personal channel
|
||||
send_websocket_notification(
|
||||
f"user_{ticket.assignee.id}",
|
||||
{
|
||||
"type": "ticket_update",
|
||||
"ticket_id": ticket.id,
|
||||
"subject": ticket.subject,
|
||||
"status": ticket.status,
|
||||
"assignee_id": str(ticket.assignee.id),
|
||||
"message": f"Ticket #{ticket.id} '{ticket.subject}' updated. Status: {ticket.status}"
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling ticket update for ticket {ticket.id}: {e}")
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ class IsTenantUser(IsAuthenticated):
|
||||
# Platform admins can do anything
|
||||
if is_platform_admin(request.user):
|
||||
return True
|
||||
# Check if user has ticket access permission
|
||||
if hasattr(request.user, 'can_access_tickets') and not request.user.can_access_tickets():
|
||||
return False
|
||||
# Tenant users can only access their own tenant's data
|
||||
return hasattr(request.user, 'tenant') and request.user.tenant is not None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user