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:
poduck
2025-11-28 05:44:39 -05:00
parent c400e8a722
commit 9dabb0cb83
9 changed files with 253 additions and 28 deletions

View File

@@ -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 */}

View 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,
};
};

View File

@@ -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();

View File

@@ -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');

View File

@@ -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';

View File

@@ -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(),
}

View File

@@ -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):
"""

View File

@@ -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}")

View File

@@ -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