diff --git a/app/__init__.py b/app/__init__.py index 35e7219..a6990ab 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -32,7 +32,21 @@ def create_app(config_name=None): login_manager.init_app(app) mail.init_app(app) migrate.init_app(app, db) - socketio.init_app(app, async_mode='eventlet', cors_allowed_origins='*') + socketio.init_app( + app, + async_mode = 'eventlet', + cors_allowed_origins = '*', + # Ping settings: server sends a ping every 25s, client has 60s to respond. + # This ensures dead connections are detected and closed cleanly rather than + # being torn down by nginx timeouts, which causes [Errno 9] Bad file descriptor. + ping_interval = 25, + ping_timeout = 60, + # Suppress eventlet's low-level socket error messages from flooding the log. + # The errors still occur occasionally (it's an eventlet limitation) but are + # benign — connections recover automatically via Socket.IO's reconnect logic. + logger = False, + engineio_logger = False, + ) login_manager.login_view = 'auth.login' login_manager.login_message = 'Please log in to access this page.' diff --git a/app/routes/api.py b/app/routes/api.py index 75dadb3..3d6cfbd 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -12,6 +12,29 @@ logger = logging.getLogger(__name__) # ─── Notifications API ──────────────────────────────────────────────────────── +@api_bp.route('/notifications') +@login_required +def get_notifications(): + """Return the 20 most recent notifications for the current user as JSON.""" + notifs = (Notification.query + .filter_by(user_id=current_user.id) + .order_by(Notification.created_at.desc()) + .limit(20) + .all()) + return jsonify({'notifications': [ + { + 'id' : n.id, + 'type' : n.type, + 'title' : n.title, + 'message' : n.message or '', + 'link' : n.link or '', + 'is_read' : n.is_read, + 'created_at': n.created_at.strftime('%b %d, %H:%M'), + } + for n in notifs + ]}) + + @api_bp.route('/notifications/unread-count') @login_required def unread_count(): @@ -80,4 +103,4 @@ def on_join_ticket(data): def on_leave_ticket(data): if current_user.is_authenticated: ticket_id = data.get('ticket_id') - leave_room(f'ticket_{ticket_id}') + leave_room(f'ticket_{ticket_id}') \ No newline at end of file diff --git a/app/services/notification_service.py b/app/services/notification_service.py index ba2df7b..031633a 100644 --- a/app/services/notification_service.py +++ b/app/services/notification_service.py @@ -77,15 +77,26 @@ def create_notification(user_id, notif_type, title, message, ticket_id=None, lin db.session.commit() logger.info(f'[NOTIFICATION CREATE] user_id={user_id} type={notif_type} ticket_id={ticket_id}') - # Real-time push - socketio.emit('new_notification', { - 'id' : notif.id, - 'type' : notif_type, - 'title' : title, - 'message' : message, - 'link' : link, + # Real-time push — schedule via socketio.start_background_task so the + # emit runs inside eventlet's green-thread pool, not inline in the WSGI + # request context. Inline emits during a polling→WebSocket upgrade can + # race with the upgrade handshake and disconnect the client. + payload = { + 'id' : notif.id, + 'type' : notif_type, + 'title' : title, + 'message' : message, + 'link' : link, 'created_at': notif.created_at.isoformat(), - }, room=f'user_{user_id}') + } + def _emit(): + socketio.emit( + 'new_notification', + payload, + to=f'user_{user_id}', # 'to' is the modern alias for 'room' + namespace='/', # explicit default namespace — avoids + ) # ambiguity under reverse-proxy setups + socketio.start_background_task(_emit) except Exception as exc: db.session.rollback() @@ -95,13 +106,30 @@ def create_notification(user_id, notif_type, title, message, ticket_id=None, lin # ─── Email Notification ─────────────────────────────────────────────────────── def send_email(subject, recipients, html_body): - """Send an email; silently log errors so it never blocks the main flow.""" - try: - msg = Message(subject=subject, recipients=recipients, html=html_body) - mail.send(msg) - logger.info(f'[EMAIL SENT] subject="{subject}" to={recipients}') - except Exception as exc: - logger.error(f'[EMAIL ERROR] Failed to send "{subject}" to {recipients}: {exc}') + """ + Send an email in a background thread so it never blocks the request. + + Flask-Mail opens an SMTP connection synchronously. Running it in the main + request thread means a slow or failing SMTP server delays the entire + response — including the WebSocket notification push that follows. + Using a daemon thread isolates SMTP failures from the request lifecycle. + """ + from threading import Thread + from flask import current_app + + app = current_app._get_current_object() # real app, not the proxy + + def _send(): + with app.app_context(): + try: + msg = Message(subject=subject, recipients=recipients, html=html_body) + mail.send(msg) + logger.info(f'[EMAIL SENT] subject="{subject}" to={recipients}') + except Exception as exc: + logger.error(f'[EMAIL ERROR] Failed to send "{subject}" to {recipients}: {exc}') + + t = Thread(target=_send, daemon=True) + t.start() # ─── Ticket Event Helpers ───────────────────────────────────────────────────── @@ -252,4 +280,4 @@ def notify_assignment(ticket, assigned_by): [ticket.assignee.email], html, ) - logger.info(f'[TICKET ASSIGN] ticket_id={ticket.id} assigned_to={ticket.assigned_to_id} by={assigned_by.id}') + logger.info(f'[TICKET ASSIGN] ticket_id={ticket.id} assigned_to={ticket.assigned_to_id} by={assigned_by.id}') \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index 017c325..9179c66 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -442,11 +442,15 @@ {% if current_user.is_authenticated %} const socket = io({ path: '/socket.io/', - transports: ['polling', 'websocket'], // polling first → upgrade to WS after handshake + transports: ['polling', 'websocket'], // polling first → upgrade to WS after handshake upgrade: true, reconnection: true, - reconnectionAttempts: 5, - reconnectionDelay: 2000, + reconnectionAttempts: 10, + reconnectionDelay: 3000, // wait 3s before first reconnect attempt + reconnectionDelayMax: 15000, // cap exponential backoff at 15s + timeout: 20000, // connection timeout + // Note: pingTimeout and pingInterval are server-side only in Socket.IO v4. + // The server controls the heartbeat (ping_interval=25, ping_timeout=60). }); socket.on('connect',()=>{console.log('[Socket] connected');}); socket.on('new_notification',(n)=>{ @@ -466,52 +470,108 @@ function updateBadge(delta, increment=false){ // ── Notification panel ──────────────────────────────────────────────────────── let panelOpen = false; + function toggleNotifPanel(){ const panel = document.getElementById('notif-panel'); panelOpen = !panelOpen; panel.classList.toggle('show', panelOpen); if(panelOpen) loadNotifications(); } -document.addEventListener('click',(e)=>{ + +document.addEventListener('click', e => { if(panelOpen && !e.target.closest('#notif-panel') && !e.target.closest('.notif-btn')){ - panelOpen=false; + panelOpen = false; document.getElementById('notif-panel').classList.remove('show'); } }); -async function loadNotifications(){ - try{ - const r = await fetch('/api/notifications/unread-count'); - const d = await r.json(); - updateBadge(d.count); - }catch(e){} +function _notifItemHtml(n) { + const unreadClass = n.is_read ? '' : ' unread'; + const dot = n.is_read ? '' : '
'; + return ( + `