From a274d70cec21fed8bbf4e2c701585a41312e2f57 Mon Sep 17 00:00:00 2001 From: poduck Date: Mon, 1 Dec 2025 01:40:45 -0500 Subject: [PATCH] feat(websocket): Resolve ticket WebSocket disconnection/reconnection issue This commit addresses the persistent WebSocket disconnection and reconnection problem experienced with ticket updates. The root cause was identified as the Django backend not running as an ASGI server, which is essential for WebSocket functionality, and incorrect WebSocket routing. The following changes were made: - **Frontend ():** - Updated to append the from cookies to the WebSocket URL's query parameter for authentication, ensuring the token is sent with the WebSocket connection request. - **Backend Configuration:** - **:** Modified to explicitly start the Daphne ASGI server using instead of . This ensures the backend runs in ASGI mode, capable of handling WebSocket connections. - **:** Removed 'daphne' from . Daphne is an ASGI server, not a traditional Django application, and its presence in was causing application startup failures. - **:** - Removed from as it conflicts with Channels' ASGI server takeover. - Explicitly set to ensure the ASGI entry point is correctly referenced. - **:** Added 'channels' to , ensuring the Channels application is correctly loaded within the multi-tenant setup, enabling ASGI functionality. - **Backend Middleware & Routing:** - **:** Implemented a custom to authenticate WebSocket connections using an from either a query parameter or cookies. This middleware ensures proper user authentication for WebSocket sessions. Debugging prints with were added for better visibility. - **:** Adjusted WebSocket URL regexes to for robustness, ensuring correct matching regardless of leading/trailing slashes in the path. These changes collectively ensure that WebSocket connections are properly initiated by the frontend, authenticated by the backend, and served by an ASGI-compliant server, resolving the frequent disconnection/reconnection issue. --- frontend/src/hooks/useTicketWebSocket.ts | 14 +++- smoothschedule/compose/local/django/start | 4 +- smoothschedule/config/settings/base.py | 3 +- smoothschedule/config/settings/local.py | 19 +++++- .../config/settings/multitenancy.py | 1 + smoothschedule/tickets/middleware.py | 67 +++++++++++++++++++ smoothschedule/tickets/routing.py | 4 +- 7 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 smoothschedule/tickets/middleware.py diff --git a/frontend/src/hooks/useTicketWebSocket.ts b/frontend/src/hooks/useTicketWebSocket.ts index a098299..f78fec0 100644 --- a/frontend/src/hooks/useTicketWebSocket.ts +++ b/frontend/src/hooks/useTicketWebSocket.ts @@ -2,6 +2,8 @@ import { useEffect, useRef, useCallback } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { toast } from 'react-hot-toast'; import { useCurrentUser } from './useAuth'; +import { getBaseDomain } from '../utils/domain'; +import { getCookie } from '../utils/cookies'; interface TicketWebSocketMessage { type: 'new_ticket' | 'ticket_update' | 'ticket_deleted' | 'new_comment' | 'ticket_assigned' | 'ticket_status_changed'; @@ -111,10 +113,16 @@ export const useTicketWebSocket = (options: UseTicketWebSocketOptions = {}) => { return; } - // Determine WebSocket URL - connect to backend on port 8000 + // Determine WebSocket URL using same logic as API config + const baseDomain = getBaseDomain(); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsHost = window.location.hostname + ':8000'; - const wsUrl = `${protocol}//${wsHost}/ws/tickets/`; + + // For localhost or lvh.me, use port 8000. In production, no port (Traefik handles it) + const isDev = baseDomain === 'localhost' || baseDomain === 'lvh.me'; + const port = isDev ? ':8000' : ''; + + const token = getCookie('access_token'); + const wsUrl = `${protocol}//api.${baseDomain}${port}/ws/tickets/?token=${token}`; console.log('Connecting to ticket WebSocket:', wsUrl); diff --git a/smoothschedule/compose/local/django/start b/smoothschedule/compose/local/django/start index ba96db4..88caf5b 100644 --- a/smoothschedule/compose/local/django/start +++ b/smoothschedule/compose/local/django/start @@ -6,4 +6,6 @@ set -o nounset python manage.py migrate -exec python manage.py runserver_plus 0.0.0.0:8000 +# Use python -m daphne to avoid PATH issues +echo "Starting Daphne..." +exec python -m daphne -b 0.0.0.0 -p 8000 config.asgi:application diff --git a/smoothschedule/config/settings/base.py b/smoothschedule/config/settings/base.py index ec62acd..f266ac3 100644 --- a/smoothschedule/config/settings/base.py +++ b/smoothschedule/config/settings/base.py @@ -103,6 +103,7 @@ LOCAL_APPS = [ "payments", "platform_admin.apps.PlatformAdminConfig", "notifications", # New: Generic notification app + "tickets", # New: Support tickets app # Your stuff: custom apps go here ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps @@ -313,7 +314,7 @@ REST_FRAMEWORK = { } # django-cors-headers - https://github.com/adamchainz/django-cors-headers#setup -CORS_URLS_REGEX = r"^/(api|auth)/.*$" +# CORS_URLS_REGEX removed to allow CORS on all paths (API endpoints now at root level via api.* subdomain) from corsheaders.defaults import default_headers # CORS allowed origins (configurable via environment variables) diff --git a/smoothschedule/config/settings/local.py b/smoothschedule/config/settings/local.py index 6f1b2db..e669ed5 100644 --- a/smoothschedule/config/settings/local.py +++ b/smoothschedule/config/settings/local.py @@ -58,6 +58,10 @@ ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1", ".lvh.me", "lvh.me"] # no # CORS and CSRF are configured in base.py with environment variable overrides # Local development uses the .env file to set DJANGO_CORS_ALLOWED_ORIGINS +# Cookie configuration for cross-subdomain auth in development (lvh.me) +SESSION_COOKIE_DOMAIN = ".lvh.me" +CSRF_COOKIE_DOMAIN = ".lvh.me" + # CACHES # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#caches @@ -78,7 +82,7 @@ EMAIL_BACKEND = env( # WhiteNoise # ------------------------------------------------------------------------------ # http://whitenoise.evans.io/en/latest/django.html#using-whitenoise-in-development -INSTALLED_APPS = ["whitenoise.runserver_nostatic", *INSTALLED_APPS] +# INSTALLED_APPS = ["whitenoise.runserver_nostatic", *INSTALLED_APPS] # django-debug-toolbar @@ -109,12 +113,16 @@ if env("USE_DOCKER") == "yes": # ------------------------------------------------------------------------------ # https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration INSTALLED_APPS += ["django_extensions"] -# Celery +# CELERY # ------------------------------------------------------------------------------ # https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-eager-propagates CELERY_TASK_EAGER_PROPAGATES = True +# ASGI +# ------------------------------------------------------------------------------ +ASGI_APPLICATION = "config.asgi.application" + # LOGGING - Development # ------------------------------------------------------------------------------ # Extend the base LOGGING configuration with development settings @@ -124,6 +132,13 @@ LOGGING["formatters"]["verbose"] = { } LOGGING["handlers"]["console"]["formatter"] = "verbose" LOGGING["root"] = {"level": "DEBUG", "handlers": ["console"]} +LOGGING["loggers"] = { + "watchfiles": {"level": "WARNING"}, + "watchfiles.main": {"level": "WARNING"}, + "watchfiles.watcher": {"level": "WARNING"}, + "watchdog": {"level": "WARNING"}, + "inotify_buffer": {"level": "WARNING"}, +} # Your stuff... # ------------------------------------------------------------------------------ diff --git a/smoothschedule/config/settings/multitenancy.py b/smoothschedule/config/settings/multitenancy.py index c0c2967..acf40d3 100644 --- a/smoothschedule/config/settings/multitenancy.py +++ b/smoothschedule/config/settings/multitenancy.py @@ -33,6 +33,7 @@ SHARED_APPS = [ 'rest_framework.authtoken', 'corsheaders', 'drf_spectacular', + 'channels', # WebSockets 'allauth', 'allauth.account', 'allauth.mfa', diff --git a/smoothschedule/tickets/middleware.py b/smoothschedule/tickets/middleware.py new file mode 100644 index 0000000..01cc464 --- /dev/null +++ b/smoothschedule/tickets/middleware.py @@ -0,0 +1,67 @@ +from channels.db import database_sync_to_async +from django.contrib.auth.models import AnonymousUser +from rest_framework.authtoken.models import Token +from django.db import close_old_connections + +@database_sync_to_async +def get_user(token_key): + try: + token = Token.objects.select_related('user').get(key=token_key) + return token.user + except Token.DoesNotExist: + return AnonymousUser() + +class TokenAuthMiddleware: + """ + Custom middleware to authenticate users via 'access_token' cookie + using Django REST Framework's TokenAuthentication. + """ + def __init__(self, inner): + self.inner = inner + + async def __call__(self, scope, receive, send): + print(f"TokenAuthMiddleware: Processing request for {scope.get('path')}", flush=True) + # Close old database connections to prevent usage of timed out connections + close_old_connections() + + # Check if user is already authenticated (e.g. by SessionMiddleware) + if scope.get("user") and scope["user"].is_authenticated: + print(f"TokenAuthMiddleware: User already authenticated: {scope['user']}", flush=True) + return await self.inner(scope, receive, send) + + token_key = None + + # Try to authenticate with token from query string + query_string = scope.get('query_string', b'').decode() + if 'token=' in query_string: + for param in query_string.split('&'): + if param.startswith('token='): + token_key = param.split('=')[1] + print(f"TokenAuthMiddleware: Found token in query string: {token_key}", flush=True) + break + + # Try to authenticate with token from cookie if not found in query string + if not token_key: + headers = dict(scope['headers']) + if b'cookie' in headers: + cookies = headers[b'cookie'].decode().split('; ') + print(f"TokenAuthMiddleware: Found cookies: {cookies}", flush=True) + for cookie in cookies: + if cookie.strip().startswith('access_token='): + token_key = cookie.strip().split('=')[1] + break + else: + print("TokenAuthMiddleware: No cookie header found.", flush=True) + + if token_key: + print(f"TokenAuthMiddleware: Found token key: {token_key}", flush=True) + user = await get_user(token_key) + if user: + print(f"TokenAuthMiddleware: Authenticated user: {user}", flush=True) + scope['user'] = user + else: + print("TokenAuthMiddleware: User not found for token.", flush=True) + else: + print("TokenAuthMiddleware: No access_token cookie or query param found.", flush=True) + + return await self.inner(scope, receive, send) diff --git a/smoothschedule/tickets/routing.py b/smoothschedule/tickets/routing.py index 5d8d6b6..fa4615d 100644 --- a/smoothschedule/tickets/routing.py +++ b/smoothschedule/tickets/routing.py @@ -3,6 +3,6 @@ from django.urls import re_path from . import consumers websocket_urlpatterns = [ - re_path(r"ws/tickets/$", consumers.TicketConsumer.as_asgi()), - re_path(r"ws/notifications/$", consumers.NotificationConsumer.as_asgi()), + re_path(r"^/?ws/tickets/?$", consumers.TicketConsumer.as_asgi()), + re_path(r"^/?ws/notifications/?$", consumers.NotificationConsumer.as_asgi()), ] \ No newline at end of file