diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 82cee81..09f6516 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -51,6 +51,8 @@ const Sidebar: React.FC = ({ 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 = ({ business, user, isCollapsed, toggleCo {!isCollapsed && {t('nav.resources')}} - - - {!isCollapsed && {t('nav.tickets')}} - )} + {canViewTickets && ( + + + {!isCollapsed && {t('nav.tickets')}} + + )} + {canViewAdminPages && ( <> {/* Payments link: always visible for owners, only visible for others if enabled */} diff --git a/frontend/src/hooks/useTicketWebSocket.ts b/frontend/src/hooks/useTicketWebSocket.ts new file mode 100644 index 0000000..a098299 --- /dev/null +++ b/frontend/src/hooks/useTicketWebSocket.ts @@ -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(null); + const reconnectTimeoutRef = useRef(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, + }; +}; diff --git a/frontend/src/pages/Tickets.tsx b/frontend/src/pages/Tickets.tsx index b9854d4..f13b885 100644 --- a/frontend/src/pages/Tickets.tsx +++ b/frontend/src/pages/Tickets.tsx @@ -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(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(); diff --git a/frontend/src/pages/platform/PlatformSupport.tsx b/frontend/src/pages/platform/PlatformSupport.tsx index a404b46..41e9ca4 100644 --- a/frontend/src/pages/platform/PlatformSupport.tsx +++ b/frontend/src/pages/platform/PlatformSupport.tsx @@ -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(null); const [isModalOpen, setIsModalOpen] = useState(false); const [statusFilter, setStatusFilter] = useState('ALL'); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 3dfb826..7f69584 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -88,6 +88,9 @@ export interface User { timezone?: string; locale?: string; notification_preferences?: NotificationPreferences; + can_invite_staff?: boolean; + can_access_tickets?: boolean; + permissions?: Record; } export type ResourceType = 'STAFF' | 'ROOM' | 'EQUIPMENT'; diff --git a/smoothschedule/smoothschedule/users/api_views.py b/smoothschedule/smoothschedule/users/api_views.py index 24e75a1..e763f43 100644 --- a/smoothschedule/smoothschedule/users/api_views.py +++ b/smoothschedule/smoothschedule/users/api_views.py @@ -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(), } diff --git a/smoothschedule/smoothschedule/users/models.py b/smoothschedule/smoothschedule/users/models.py index 8b4700c..bf8ac5a 100644 --- a/smoothschedule/smoothschedule/users/models.py +++ b/smoothschedule/smoothschedule/users/models.py @@ -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): """ diff --git a/smoothschedule/tickets/signals.py b/smoothschedule/tickets/signals.py index 4f85271..26e19dc 100644 --- a/smoothschedule/tickets/signals.py +++ b/smoothschedule/tickets/signals.py @@ -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}") diff --git a/smoothschedule/tickets/views.py b/smoothschedule/tickets/views.py index 87dca6e..5f8b630 100644 --- a/smoothschedule/tickets/views.py +++ b/smoothschedule/tickets/views.py @@ -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