# /home/Autopartes/pos/services/push_service.py """Web Push notification service for Nexus POS. Uses the pywebpush library to send push notifications via the Web Push API. VAPID keys are generated once and stored in the tenant config table. Usage: from services.push_service import notify_owner # Non-blocking: fire-and-forget push to owner(s) notify_owner(conn, 'Venta Cancelada', f'Venta #{sale_id} cancelada por {reason}', '/pos') """ import json import logging import os import traceback logger = logging.getLogger(__name__) # Try to import pywebpush — graceful degradation if not installed try: from pywebpush import webpush, WebPushException HAS_WEBPUSH = True except ImportError: HAS_WEBPUSH = False logger.warning("pywebpush not installed — push notifications disabled. Install with: pip install pywebpush") try: from py_vapid import Vapid HAS_VAPID = True except ImportError: HAS_VAPID = False def get_or_create_vapid_keys(conn): """Get VAPID keys from config, or generate new ones. Returns: (private_key_pem, public_key_b64url) or (None, None) if unavailable. """ if not HAS_VAPID and not HAS_WEBPUSH: return None, None cur = conn.cursor() # Check if keys already exist in config try: cur.execute("SELECT value FROM config WHERE key = 'vapid_private_key'") row = cur.fetchone() if row: private_key = row[0] cur.execute("SELECT value FROM config WHERE key = 'vapid_public_key'") pub_row = cur.fetchone() public_key = pub_row[0] if pub_row else None cur.close() if public_key: return private_key, public_key except Exception: pass # config table might not exist yet # Generate new VAPID keys try: vapid = Vapid() vapid.generate_keys() private_pem = vapid.private_pem().decode('utf-8') public_b64 = vapid.public_key_urlsafe_base64() # Store in config table try: cur.execute(""" INSERT INTO config (key, value) VALUES ('vapid_private_key', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """, (private_pem,)) cur.execute(""" INSERT INTO config (key, value) VALUES ('vapid_public_key', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """, (public_b64,)) conn.commit() except Exception: conn.rollback() cur.close() return private_pem, public_b64 except Exception as e: logger.error(f"Failed to generate VAPID keys: {e}") cur.close() return None, None def save_subscription(conn, employee_id, subscription_json): """Save a push subscription for an employee. Args: conn: psycopg2 connection employee_id: int subscription_json: dict (the PushSubscription object from the browser) """ cur = conn.cursor() sub_str = json.dumps(subscription_json) if isinstance(subscription_json, dict) else subscription_json # Upsert: one subscription per employee cur.execute(""" INSERT INTO push_subscriptions (employee_id, subscription_data, created_at) VALUES (%s, %s, NOW()) ON CONFLICT (employee_id) DO UPDATE SET subscription_data = EXCLUDED.subscription_data, created_at = NOW() """, (employee_id, sub_str)) conn.commit() cur.close() def send_push(conn, employee_id, title, body, url=None): """Send a push notification to a specific employee. Returns True on success, False on failure. """ if not HAS_WEBPUSH: logger.debug("pywebpush not available, skipping push") return False cur = conn.cursor() # Get subscription cur.execute("SELECT subscription_data FROM push_subscriptions WHERE employee_id = %s", (employee_id,)) row = cur.fetchone() if not row: cur.close() return False subscription = json.loads(row[0]) if isinstance(row[0], str) else row[0] # Get VAPID keys private_key, public_key = get_or_create_vapid_keys(conn) if not private_key: cur.close() return False cur.close() payload = json.dumps({ 'title': title, 'body': body, 'url': url or '/pos', 'icon': '/pos/static/icons/icon-192.png', 'badge': '/pos/static/icons/badge-72.png', }) try: webpush( subscription_info=subscription, data=payload, vapid_private_key=private_key, vapid_claims={'sub': 'mailto:notificaciones@nexusautoparts.mx'}, ) return True except WebPushException as e: logger.warning(f"Push failed for employee {employee_id}: {e}") # If subscription expired, remove it if e.response and e.response.status_code in (404, 410): cur2 = conn.cursor() cur2.execute("DELETE FROM push_subscriptions WHERE employee_id = %s", (employee_id,)) conn.commit() cur2.close() return False except Exception as e: logger.warning(f"Push error: {e}") return False def notify_owner(conn, title, body, url=None): """Send push notification to all owner/admin employees (non-blocking, best-effort). This is the main integration point — call from sale cancellation, stock alerts, etc. """ if not HAS_WEBPUSH: return cur = conn.cursor() try: cur.execute(""" SELECT ps.employee_id FROM push_subscriptions ps JOIN employees e ON e.id = ps.employee_id WHERE e.role IN ('owner', 'admin') AND e.is_active = true """) employee_ids = [r[0] for r in cur.fetchall()] cur.close() except Exception: cur.close() return for eid in employee_ids: try: send_push(conn, eid, title, body, url) except Exception: pass # Never let push failures affect business logic def ensure_push_table(conn): """Create push_subscriptions table if it doesn't exist.""" cur = conn.cursor() try: cur.execute(""" CREATE TABLE IF NOT EXISTS push_subscriptions ( id SERIAL PRIMARY KEY, employee_id INTEGER NOT NULL UNIQUE, subscription_data TEXT NOT NULL, created_at TIMESTAMP DEFAULT NOW() ) """) conn.commit() except Exception: conn.rollback() finally: cur.close()