"""CRUD service for Webhook subscriptions and delivery.""" from __future__ import annotations import hashlib import hmac import json import logging import uuid from typing import Any import httpx from sqlalchemy import select, delete from sqlalchemy.ext.asyncio import AsyncSession from app.models.webhook import Webhook logger = logging.getLogger(__name__) async def list_webhooks( db: AsyncSession, tenant_id: uuid.UUID, event: str | None = None, ) -> list[Webhook]: """List webhooks for a tenant, optionally filtered by event.""" stmt = select(Webhook).where( Webhook.tenant_id == tenant_id, ) if event: stmt = stmt.where(Webhook.events.any(event)) stmt = stmt.order_by(Webhook.created_at.desc()) result = await db.execute(stmt) return list(result.scalars().all()) async def get_webhook( db: AsyncSession, tenant_id: uuid.UUID, webhook_id: uuid.UUID, ) -> Webhook | None: """Get a single webhook by ID.""" stmt = select(Webhook).where( Webhook.id == webhook_id, Webhook.tenant_id == tenant_id, ) result = await db.execute(stmt) return result.scalar_one_or_none() async def create_webhook( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict[str, Any], ) -> Webhook: """Create a new webhook subscription.""" webhook = Webhook( tenant_id=tenant_id, url=data["url"], events=data["events"], secret=data.get("secret"), is_active=data.get("is_active", True), retry_count=data.get("retry_count", 3), timeout_seconds=data.get("timeout_seconds", 30), created_by=user_id, updated_by=user_id, ) db.add(webhook) await db.flush() await db.refresh(webhook) return webhook async def update_webhook( db: AsyncSession, tenant_id: uuid.UUID, webhook_id: uuid.UUID, data: dict[str, Any], user_id: uuid.UUID | None = None, ) -> Webhook | None: """Update an existing webhook subscription.""" webhook = await get_webhook(db, tenant_id, webhook_id) if webhook is None: return None update_fields = ["url", "events", "secret", "is_active", "retry_count", "timeout_seconds"] for field in update_fields: if field in data: setattr(webhook, field, data[field]) if user_id is not None: webhook.updated_by = user_id await db.flush() await db.refresh(webhook) return webhook async def delete_webhook( db: AsyncSession, tenant_id: uuid.UUID, webhook_id: uuid.UUID, ) -> bool: """Delete a webhook subscription.""" stmt = select(Webhook).where( Webhook.id == webhook_id, Webhook.tenant_id == tenant_id, ) result = await db.execute(stmt) webhook = result.scalar_one_or_none() if webhook is None: return False await db.delete(webhook) await db.flush() return True def _sign_payload(payload: bytes, secret: str) -> str: """Create HMAC-SHA256 signature for a payload.""" return hmac.new( secret.encode("utf-8"), payload, hashlib.sha256, ).hexdigest() async def send_webhook( webhook: Webhook, event_name: str, payload: dict[str, Any], ) -> dict[str, Any]: """Send a webhook HTTP POST with HMAC signature. Returns a dict with 'success' (bool), 'status_code' (int | None), and 'error' (str | None). """ body = { "event": event_name, "payload": payload, "timestamp": __import__("datetime").datetime.utcnow().isoformat() + "Z", } body_bytes = json.dumps(body, default=str).encode("utf-8") headers = { "Content-Type": "application/json", "User-Agent": "LeoCRM-Webhook/1.0", } if webhook.secret: signature = _sign_payload(body_bytes, webhook.secret) headers["X-Webhook-Signature"] = f"sha256={signature}" try: async with httpx.AsyncClient(timeout=webhook.timeout_seconds) as client: response = await client.post( webhook.url, content=body_bytes, headers=headers, ) return { "success": response.is_success, "status_code": response.status_code, "error": None, } except httpx.TimeoutException: return {"success": False, "status_code": None, "error": "timeout"} except httpx.RequestError as exc: return {"success": False, "status_code": None, "error": str(exc)} async def retry_webhook( db: AsyncSession, tenant_id: uuid.UUID, webhook_id: uuid.UUID, event_name: str, payload: dict[str, Any], ) -> dict[str, Any]: """Retry sending a webhook with the given event and payload.""" webhook = await get_webhook(db, tenant_id, webhook_id) if webhook is None: return {"success": False, "status_code": None, "error": "webhook_not_found"} return await send_webhook(webhook, event_name, payload)