d167be8274
Add REST hooks subscription endpoints, incoming action endpoints, and
Zapier-compatible flat payload format for automation platform integration.
- AutomationHook model for webhook subscriptions
- POST /api/automation/hooks/subscribe and DELETE /hooks/{id}
- GET /api/automation/triggers/sample/{event} for Zapier field mapping
- POST /api/automation/actions/upload for incoming document uploads
- Celery task with retry for async hook delivery
- Integration with existing webhook dispatch flow
- 30 passing tests covering all new functionality
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
189 lines
6.0 KiB
Python
189 lines
6.0 KiB
Python
"""Automation hook utilities for Zapier / Make.com integration.
|
|
|
|
Provides helpers to build Zapier-compatible flat payloads, query active
|
|
automation hook subscriptions, and fan-out event delivery to all matching
|
|
hooks via Celery tasks.
|
|
|
|
The payload format is intentionally *flat* (no nested ``data`` key) so that
|
|
Zapier and Make.com can map fields without JSONPath expressions. An ``id``
|
|
field is included for Zapier deduplication.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import time
|
|
import uuid
|
|
from typing import Any
|
|
|
|
from app.config import settings
|
|
from app.database import SessionLocal
|
|
from app.models import AutomationHook
|
|
from app.utils.webhook import VALID_EVENTS
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Payload helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def build_zapier_payload(event: str, data: dict[str, Any]) -> dict[str, Any]:
|
|
"""Build a flat, Zapier-compatible webhook payload.
|
|
|
|
Zapier works best with flat JSON objects that include an ``id`` field
|
|
for deduplication. This function merges event metadata into the
|
|
top-level object alongside the event-specific *data*.
|
|
|
|
Args:
|
|
event: The event name (e.g. ``document.processed``).
|
|
data: Event-specific key/value pairs.
|
|
|
|
Returns:
|
|
A flat dictionary suitable for Zapier / Make.com consumption.
|
|
"""
|
|
return {
|
|
"id": f"evt_{uuid.uuid4().hex[:16]}",
|
|
"event": event,
|
|
"timestamp": time.time(),
|
|
**data,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sample payloads (used by the /triggers/sample endpoint)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
#: Example payloads that Zapier uses for field-mapping during Zap creation.
|
|
SAMPLE_PAYLOADS: dict[str, dict[str, Any]] = {
|
|
"document.uploaded": {
|
|
"id": "evt_sample0001",
|
|
"event": "document.uploaded",
|
|
"timestamp": 1710000000.0,
|
|
"document_id": 42,
|
|
"filename": "invoice_2024.pdf",
|
|
"content_type": "application/pdf",
|
|
"size_bytes": 204800,
|
|
"owner_id": "user@example.com",
|
|
},
|
|
"document.processed": {
|
|
"id": "evt_sample0002",
|
|
"event": "document.processed",
|
|
"timestamp": 1710000060.0,
|
|
"document_id": 42,
|
|
"filename": "invoice_2024.pdf",
|
|
"status": "processed",
|
|
"title": "Invoice #1234",
|
|
"owner_id": "user@example.com",
|
|
},
|
|
"document.failed": {
|
|
"id": "evt_sample0003",
|
|
"event": "document.failed",
|
|
"timestamp": 1710000120.0,
|
|
"document_id": 42,
|
|
"filename": "corrupt.pdf",
|
|
"status": "failed",
|
|
"error": "Unable to extract text from document",
|
|
"owner_id": "user@example.com",
|
|
},
|
|
"user.signup": {
|
|
"id": "evt_sample0004",
|
|
"event": "user.signup",
|
|
"timestamp": 1710000180.0,
|
|
"user_id": "newuser@example.com",
|
|
"display_name": "Jane Doe",
|
|
},
|
|
"user.plan_changed": {
|
|
"id": "evt_sample0005",
|
|
"event": "user.plan_changed",
|
|
"timestamp": 1710000240.0,
|
|
"user_id": "user@example.com",
|
|
"old_tier": "free",
|
|
"new_tier": "pro",
|
|
},
|
|
"user.payment_issue": {
|
|
"id": "evt_sample0006",
|
|
"event": "user.payment_issue",
|
|
"timestamp": 1710000300.0,
|
|
"user_id": "user@example.com",
|
|
"issue": "Credit card declined",
|
|
},
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Database queries
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def get_active_hooks_for_event(event: str) -> list[dict[str, Any]]:
|
|
"""Return all active automation hooks subscribed to *event*.
|
|
|
|
Args:
|
|
event: The event name to filter on.
|
|
|
|
Returns:
|
|
A list of dicts with ``id``, ``target_url``, ``secret``, and
|
|
``events`` keys.
|
|
"""
|
|
db = SessionLocal()
|
|
try:
|
|
hooks = db.query(AutomationHook).filter(AutomationHook.is_active.is_(True)).all()
|
|
result: list[dict[str, Any]] = []
|
|
for hook in hooks:
|
|
try:
|
|
subscribed = json.loads(hook.events)
|
|
except (json.JSONDecodeError, TypeError):
|
|
subscribed = []
|
|
if event in subscribed:
|
|
result.append(
|
|
{
|
|
"id": hook.id,
|
|
"target_url": hook.target_url,
|
|
"secret": hook.secret,
|
|
"events": subscribed,
|
|
}
|
|
)
|
|
return result
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dispatch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def dispatch_automation_hooks(event: str, data: dict[str, Any]) -> None:
|
|
"""Fan-out an event to all matching active automation hooks.
|
|
|
|
Builds a Zapier-compatible flat payload and queues a Celery task for
|
|
each matching hook so delivery is asynchronous with automatic retries.
|
|
|
|
Args:
|
|
event: Event name (must be in :data:`VALID_EVENTS`).
|
|
data: Event-specific payload data.
|
|
"""
|
|
if not settings.automation_hooks_enabled:
|
|
return
|
|
|
|
if event not in VALID_EVENTS:
|
|
logger.warning("Ignoring unknown automation hook event: %s", event)
|
|
return
|
|
|
|
hooks = get_active_hooks_for_event(event)
|
|
if not hooks:
|
|
logger.debug("No active automation hooks for event %s", event)
|
|
return
|
|
|
|
payload = build_zapier_payload(event, data)
|
|
|
|
from app.tasks.automation_tasks import deliver_automation_hook_task
|
|
|
|
for hook in hooks:
|
|
try:
|
|
deliver_automation_hook_task.delay(hook["target_url"], payload, hook["secret"])
|
|
logger.debug("Queued automation hook delivery to %s for event %s", hook["target_url"], event)
|
|
except Exception as exc:
|
|
logger.error("Failed to queue automation hook to %s: %s", hook["target_url"], exc)
|