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 canViewAdminPages = role === 'owner' || role === 'manager';
|
||||||
const canViewManagementPages = role === 'owner' || role === 'manager' || role === 'staff';
|
const canViewManagementPages = role === 'owner' || role === 'manager' || role === 'staff';
|
||||||
const canViewSettings = role === 'owner';
|
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 = () => {
|
const getDashboardLink = () => {
|
||||||
if (role === 'resource') return '/';
|
if (role === 'resource') return '/';
|
||||||
@@ -135,13 +137,16 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
<ClipboardList size={20} className="shrink-0" />
|
<ClipboardList size={20} className="shrink-0" />
|
||||||
{!isCollapsed && <span>{t('nav.resources')}</span>}
|
{!isCollapsed && <span>{t('nav.resources')}</span>}
|
||||||
</Link>
|
</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 && (
|
{canViewAdminPages && (
|
||||||
<>
|
<>
|
||||||
{/* Payments link: always visible for owners, only visible for others if enabled */}
|
{/* 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 { useTranslation } from 'react-i18next';
|
||||||
import { Plus, User, Clock, AlertCircle, CheckCircle, XCircle, Circle, Loader2 } from 'lucide-react';
|
import { Plus, User, Clock, AlertCircle, CheckCircle, XCircle, Circle, Loader2 } from 'lucide-react';
|
||||||
import { useTickets } from '../hooks/useTickets';
|
import { useTickets } from '../hooks/useTickets';
|
||||||
|
import { useTicketWebSocket } from '../hooks/useTicketWebSocket';
|
||||||
import { Ticket, TicketStatus } from '../types';
|
import { Ticket, TicketStatus } from '../types';
|
||||||
import TicketModal from '../components/TicketModal';
|
import TicketModal from '../components/TicketModal';
|
||||||
import { useCurrentUser } from '../hooks/useAuth';
|
import { useCurrentUser } from '../hooks/useAuth';
|
||||||
@@ -82,6 +83,9 @@ const Tickets: React.FC = () => {
|
|||||||
const [isTicketModalOpen, setIsTicketModalOpen] = useState(false);
|
const [isTicketModalOpen, setIsTicketModalOpen] = useState(false);
|
||||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
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)
|
// Fetch all tickets (backend will filter based on user role)
|
||||||
const { data: tickets = [], isLoading, error } = useTickets();
|
const { data: tickets = [], isLoading, error } = useTickets();
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ import React, { useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Ticket as TicketIcon, AlertCircle, CheckCircle2, Clock, Circle, Loader2, XCircle, Plus } from 'lucide-react';
|
import { Ticket as TicketIcon, AlertCircle, CheckCircle2, Clock, Circle, Loader2, XCircle, Plus } from 'lucide-react';
|
||||||
import { useTickets } from '../../hooks/useTickets';
|
import { useTickets } from '../../hooks/useTickets';
|
||||||
|
import { useTicketWebSocket } from '../../hooks/useTicketWebSocket';
|
||||||
import { Ticket, TicketStatus } from '../../types';
|
import { Ticket, TicketStatus } from '../../types';
|
||||||
import TicketModal from '../../components/TicketModal';
|
import TicketModal from '../../components/TicketModal';
|
||||||
import Portal from '../../components/Portal';
|
import Portal from '../../components/Portal';
|
||||||
|
|
||||||
const PlatformSupport: React.FC = () => {
|
const PlatformSupport: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Enable real-time ticket updates via WebSocket
|
||||||
|
useTicketWebSocket({ showToasts: true });
|
||||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [statusFilter, setStatusFilter] = useState<TicketStatus | 'ALL'>('ALL');
|
const [statusFilter, setStatusFilter] = useState<TicketStatus | 'ALL'>('ALL');
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ export interface User {
|
|||||||
timezone?: string;
|
timezone?: string;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
notification_preferences?: NotificationPreferences;
|
notification_preferences?: NotificationPreferences;
|
||||||
|
can_invite_staff?: boolean;
|
||||||
|
can_access_tickets?: boolean;
|
||||||
|
permissions?: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ResourceType = 'STAFF' | 'ROOM' | 'EQUIPMENT';
|
export type ResourceType = 'STAFF' | 'ROOM' | 'EQUIPMENT';
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ def current_user_view(request):
|
|||||||
'business_subdomain': business_subdomain,
|
'business_subdomain': business_subdomain,
|
||||||
'permissions': user.permissions,
|
'permissions': user.permissions,
|
||||||
'can_invite_staff': user.can_invite_staff(),
|
'can_invite_staff': user.can_invite_staff(),
|
||||||
|
'can_access_tickets': user.can_access_tickets(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response(user_data, status=status.HTTP_200_OK)
|
return Response(user_data, status=status.HTTP_200_OK)
|
||||||
@@ -212,6 +213,7 @@ def _get_user_data(user):
|
|||||||
'business_subdomain': business_subdomain,
|
'business_subdomain': business_subdomain,
|
||||||
'permissions': user.permissions,
|
'permissions': user.permissions,
|
||||||
'can_invite_staff': user.can_invite_staff(),
|
'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:
|
if self.role == self.Role.TENANT_MANAGER:
|
||||||
return self.permissions.get('can_invite_staff', False)
|
return self.permissions.get('can_invite_staff', False)
|
||||||
return 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):
|
def get_accessible_tenants(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -190,32 +190,46 @@ def _handle_ticket_creation(ticket):
|
|||||||
def _handle_ticket_update(ticket):
|
def _handle_ticket_update(ticket):
|
||||||
"""Send notifications when a ticket is updated."""
|
"""Send notifications when a ticket is updated."""
|
||||||
try:
|
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
|
# Notify assignee if one exists
|
||||||
if not ticket.assignee:
|
if ticket.assignee:
|
||||||
return
|
# 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:
|
except Exception as e:
|
||||||
logger.error(f"Error handling ticket update for ticket {ticket.id}: {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
|
# Platform admins can do anything
|
||||||
if is_platform_admin(request.user):
|
if is_platform_admin(request.user):
|
||||||
return True
|
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
|
# Tenant users can only access their own tenant's data
|
||||||
return hasattr(request.user, 'tenant') and request.user.tenant is not None
|
return hasattr(request.user, 'tenant') and request.user.tenant is not None
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user