Phase 4: Webhooks, Backup/Restore UI, Onboarding/Tutorial
- Webhooks Backend: model, schema, service (HMAC-SHA256, httpx), routes, event bus dispatcher, migration 0042 - Webhooks Frontend: SettingsWebhooksPage (CRUD, test button, event multi-select), API client - Backup/Restore Backend: model, schema, service (pg_dump/pg_restore), routes (admin-only), migration 0043 - Backup/Restore Frontend: SettingsBackupPage (create, list, restore dialog with RESTORE confirmation, auto-refresh) - Onboarding: OnboardingTour (8 steps, custom CSS overlay), WelcomeDialog, onboardingStore (zustand + localStorage) - Onboarding integrated into AppShell - Routes: /settings/webhooks, /settings/backup registered - Settings nav: Webhooks, Backup & Restore entries added - Migration conflict fixed: 0042_webhooks → 0043_backups chain
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user