chore: merge main into copilot/sub-pr-774 and resolve conflict in test_upload_handlers.py

This commit is contained in:
copilot-swe-agent[bot]
2026-03-21 14:23:06 +00:00
23 changed files with 1308 additions and 25 deletions
+1 -1
View File
@@ -1 +1 @@
2026-03-20T23:38:07Z
2026-03-21T14:21:09Z
+38
View File
@@ -10,6 +10,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- version list -->
## v0.164.0 (2026-03-21)
### Bug Fixes
- Merge main branch and renumber migration 027→037
([`204000a`](https://github.com/christianlouis/DocuElevate/commit/204000aabcf94bd9eeff7cb67d8707e9af8b7fa3))
- Merge main branch and renumber migration 037→040
([`e518bce`](https://github.com/christianlouis/DocuElevate/commit/e518bce922524ae8a12372abe582a6feb5efe3aa))
- **automation**: Address code review - path traversal fix and test marker
([`6a83d51`](https://github.com/christianlouis/DocuElevate/commit/6a83d51d888a20045ad2a3e4e68d3205329e3856))
- **automation**: Register automation task in celery worker and add docs
([`ce2a76f`](https://github.com/christianlouis/DocuElevate/commit/ce2a76fb770da3257b31cb5958c888821a23da0c))
- **docs**: Remove duplicate Further Assistance heading in API.md
([`34ff7f8`](https://github.com/christianlouis/DocuElevate/commit/34ff7f8de877cc0a7ae3b000cb3199cf017be16c))
### Features
- **automation**: Add Zapier and Make.com integration
([`d167be8`](https://github.com/christianlouis/DocuElevate/commit/d167be827421b3abf19441a2dea052abb486b567))
## v0.163.1 (2026-03-21)
### Bug Fixes
- **api**: Add missing `import requests` in dropbox.py and onedrive.py to fix ruff F821
([`35caf24`](https://github.com/christianlouis/DocuElevate/commit/35caf24e3c0f4034b200038fd507a4747b6785f6))
### Testing
- Add -- separator assertions to rclone and ocrmypdf tests
([`81484ad`](https://github.com/christianlouis/DocuElevate/commit/81484ad770e859e49da2a4bb5cd0ab01edf89de1))
## v0.163.0 (2026-03-20)
### Bug Fixes
+1 -1
View File
@@ -1 +1 @@
6f5a73f
a27a4ce
+6 -6
View File
@@ -1,10 +1,10 @@
DocuElevate Build Information
==============================
Version: 0.163.0
Build Date: 2026-03-20T23:38:07Z
Git Commit: 6f5a73f98ae3e890a232e5202521b1a263cfe5d0
Git Short SHA: 6f5a73f
Version: 0.164.0
Build Date: 2026-03-21T14:21:09Z
Git Commit: a27a4ce130515d2d214b03cd4275b3414cc24c1f
Git Short SHA: a27a4ce
Git Branch: main
Commit Date: 2026-03-21T00:37:49+01:00
Build Timestamp: 2026-03-20T23:38:07Z
Commit Date: 2026-03-21T15:20:42+01:00
Build Timestamp: 2026-03-21T14:21:09Z
==============================
+1 -1
View File
@@ -1 +1 @@
0.163.0
0.164.0
+2
View File
@@ -9,6 +9,7 @@ from fastapi import APIRouter
from app.api.admin_users import router as admin_users_router
from app.api.api_tokens import router as api_tokens_router
from app.api.audit_logs import router as audit_logs_router
from app.api.automation import router as automation_router
from app.api.azure import router as azure_router
from app.api.backup import router as backup_router
from app.api.billing import router as billing_router
@@ -106,3 +107,4 @@ router.include_router(compliance_router)
router.include_router(system_reset_router)
router.include_router(translation_router)
router.include_router(classification_rules_router)
router.include_router(automation_router)
+311
View File
@@ -0,0 +1,311 @@
"""API endpoints for Zapier / Make.com automation integration.
Provides a REST hooks subscription interface for outgoing triggers and
incoming action endpoints that external automation platforms can call.
Outgoing triggers:
External platforms subscribe to DocuElevate events via
``POST /api/automation/hooks/subscribe``. When a subscribed event
fires, DocuElevate POSTs a flat Zapier-compatible JSON payload to the
registered ``target_url``.
Incoming actions:
``POST /api/automation/actions/upload`` allows automation platforms to
push documents into DocuElevate for processing.
Authentication:
All endpoints require a valid API token via ``Authorization: Bearer``
header.
"""
import json
import logging
import os
import tempfile
from typing import Annotated, Any
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models import AutomationHook
from app.utils.automation_hooks import SAMPLE_PAYLOADS
from app.utils.webhook import VALID_EVENTS
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/automation", tags=["automation"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Auth helper require a valid API token (Bearer)
# ---------------------------------------------------------------------------
def _require_api_user(request: Request) -> dict:
"""Ensure the caller is authenticated via session or API token.
Raises:
HTTPException: 401 if not authenticated, 403 if automation hooks are disabled.
"""
if not settings.automation_hooks_enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Automation hooks are disabled",
)
# Check for API-token user first (set by auth middleware)
user = getattr(request.state, "api_token_user", None)
if user:
return user
# Fall back to session user
user = request.session.get("user")
if user:
return user
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required (Bearer token or session)",
)
AuthUser = Annotated[dict, Depends(_require_api_user)]
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class HookSubscribe(BaseModel):
"""Schema for subscribing to automation hook events."""
target_url: str = Field(..., min_length=1, max_length=2048, description="URL to POST event payloads to")
events: list[str] = Field(..., min_length=1, description="Event types to subscribe to")
secret: str | None = Field(default=None, max_length=512, description="Optional HMAC-SHA256 signing secret")
hook_type: str = Field(
default="generic",
max_length=50,
description="Platform identifier (zapier, make, generic)",
)
description: str | None = Field(default=None, max_length=500, description="Optional human-readable label")
class HookResponse(BaseModel):
"""Schema returned when listing or creating hooks."""
id: int
target_url: str
events: list[str]
is_active: bool
hook_type: str
description: str | None
has_secret: bool
model_config = {"from_attributes": True}
class ActionUploadResponse(BaseModel):
"""Response after an automation action uploads a document."""
status: str
filename: str
task_id: str | None = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _validate_events(events: list[str]) -> None:
"""Raise 422 if any event name is not recognised."""
invalid = set(events) - VALID_EVENTS
if invalid:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid event(s): {', '.join(sorted(invalid))}. Valid: {', '.join(sorted(VALID_EVENTS))}",
)
def _hook_to_response(hook: AutomationHook) -> dict[str, Any]:
"""Convert a DB model instance to a response dict."""
try:
events = json.loads(hook.events)
except (json.JSONDecodeError, TypeError):
events = []
return {
"id": hook.id,
"target_url": hook.target_url,
"events": events,
"is_active": hook.is_active,
"hook_type": hook.hook_type,
"description": hook.description,
"has_secret": hook.secret is not None and len(hook.secret) > 0,
}
# ---------------------------------------------------------------------------
# Outgoing triggers REST hooks subscription endpoints
# ---------------------------------------------------------------------------
@router.post(
"/hooks/subscribe",
status_code=status.HTTP_201_CREATED,
summary="Subscribe to automation events (REST hooks)",
)
def subscribe_hook(body: HookSubscribe, db: DbSession, user: AuthUser) -> dict[str, Any]:
"""Register a new automation hook subscription.
Zapier and Make.com call this endpoint to subscribe to DocuElevate
events. When an event fires, a flat JSON payload is POSTed to
``target_url``.
"""
_validate_events(body.events)
hook = AutomationHook(
target_url=body.target_url,
secret=body.secret,
events=json.dumps(sorted(body.events)),
is_active=True,
hook_type=body.hook_type or "generic",
description=body.description,
)
try:
db.add(hook)
db.commit()
db.refresh(hook)
except Exception:
db.rollback()
raise
logger.info("Automation hook %d created (type=%s) for events %s", hook.id, hook.hook_type, body.events)
return _hook_to_response(hook)
@router.delete(
"/hooks/{hook_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Unsubscribe an automation hook",
)
def unsubscribe_hook(hook_id: int, db: DbSession, user: AuthUser) -> None:
"""Remove an automation hook subscription.
Zapier calls this endpoint when a Zap is turned off or deleted.
"""
hook = db.query(AutomationHook).filter(AutomationHook.id == hook_id).first()
if not hook:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hook not found")
try:
db.delete(hook)
db.commit()
except Exception:
db.rollback()
raise
logger.info("Automation hook %d deleted", hook_id)
@router.get("/hooks", summary="List automation hook subscriptions")
def list_hooks(db: DbSession, user: AuthUser) -> list[dict[str, Any]]:
"""Return all active automation hook subscriptions."""
hooks = db.query(AutomationHook).order_by(AutomationHook.id).all()
return [_hook_to_response(h) for h in hooks]
# ---------------------------------------------------------------------------
# Outgoing triggers sample data for Zapier field mapping
# ---------------------------------------------------------------------------
@router.get("/triggers/sample/{event}", summary="Get sample trigger data")
def get_trigger_sample(event: str, user: AuthUser) -> list[dict[str, Any]]:
"""Return sample payload data for the given event type.
Zapier uses this during Zap setup to discover available fields and
provide a mapping interface. The response is wrapped in an array
as Zapier expects.
"""
if event not in VALID_EVENTS:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Unknown event: {event}. Valid: {', '.join(sorted(VALID_EVENTS))}",
)
sample = SAMPLE_PAYLOADS.get(event, {"id": "evt_sample", "event": event, "timestamp": 0})
return [sample]
# ---------------------------------------------------------------------------
# Outgoing triggers list valid events
# ---------------------------------------------------------------------------
@router.get("/events", summary="List valid automation event types")
def list_events(user: AuthUser) -> list[str]:
"""Return the list of valid event types that automation hooks can subscribe to."""
return sorted(VALID_EVENTS)
# ---------------------------------------------------------------------------
# Incoming actions endpoints that Zapier / Make.com can call
# ---------------------------------------------------------------------------
@router.post("/actions/upload", summary="Upload a document (incoming action)")
def action_upload(
request: Request,
db: DbSession,
user: AuthUser,
file: UploadFile = File(...),
) -> dict[str, Any]:
"""Accept a document upload from an automation platform.
This endpoint allows Zapier or Make.com to push a document into
DocuElevate for processing. The file is saved to the work directory
and a background processing task is queued.
"""
if not file.filename:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Filename is required")
# Sanitise filename to prevent path traversal attacks
safe_filename = os.path.basename(file.filename)
if not safe_filename:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Filename is required")
owner_id = user.get("preferred_username") or user.get("email") or user.get("id", "automation")
workdir = settings.workdir or tempfile.gettempdir()
upload_dir = os.path.join(workdir, "uploads")
os.makedirs(upload_dir, exist_ok=True)
dest_path = os.path.join(upload_dir, safe_filename)
try:
contents = file.file.read()
with open(dest_path, "wb") as f:
f.write(contents)
except Exception as exc:
logger.error("Failed to save uploaded file: %s", exc)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to save file")
# Queue background processing
task_id = None
try:
from app.tasks.process_document import process_document
result = process_document.delay(dest_path, owner_id)
task_id = result.id
logger.info("Automation upload queued: file=%s, task=%s, owner=%s", safe_filename, task_id, owner_id)
except Exception as exc:
logger.warning("Could not queue processing task (Celery may be unavailable): %s", exc)
return {
"status": "accepted",
"filename": safe_filename,
"task_id": task_id,
}
+1
View File
@@ -8,6 +8,7 @@ from typing import Annotated, Optional
from urllib.parse import quote
import httpx
import requests
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from sqlalchemy.orm import Session
+1
View File
@@ -7,6 +7,7 @@ from datetime import datetime, timedelta
from typing import Annotated, Optional
import httpx
import requests
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from sqlalchemy.orm import Session
+1
View File
@@ -10,6 +10,7 @@ from app import tasks # noqa: F401 - Imports app/tasks.py so Celery can registe
# Import the shared Celery instance
from app.celery_app import celery
from app.config import settings
from app.tasks.automation_tasks import deliver_automation_hook_task # noqa: F401
from app.tasks.backup_tasks import cleanup_old_backups, create_backup # noqa: F401
from app.tasks.batch_tasks import ( # noqa: F401
backfill_missing_metadata,
+6
View File
@@ -878,6 +878,12 @@ class Settings(BaseSettings):
description="Enable webhook delivery for document events",
)
# Automation hooks (Zapier / Make.com)
automation_hooks_enabled: bool = Field(
default=True,
description="Enable Zapier / Make.com automation hook subscriptions and delivery",
)
# ── Backup / restore settings ──────────────────────────────────────────────
backup_enabled: bool = Field(
default=True,
+22
View File
@@ -211,6 +211,28 @@ class WebhookConfig(Base):
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class AutomationHook(Base):
"""Zapier / Make.com compatible webhook subscription for automation triggers.
External automation platforms subscribe to DocuElevate events via the REST
hooks protocol. When an event fires, DocuElevate POSTs a Zapier-compatible
flat JSON payload to ``target_url``. The ``hook_type`` field records which
platform created the subscription (informational only).
"""
__tablename__ = "automation_hooks"
id = Column(Integer, primary_key=True, index=True)
target_url = Column(String, nullable=False) # URL to POST events to
secret = Column(String, nullable=True) # Optional HMAC-SHA256 signing secret
events = Column(Text, nullable=False) # JSON list of subscribed event names
is_active = Column(Boolean, default=True, nullable=False)
hook_type = Column(String(50), nullable=False, default="generic") # zapier | make | generic
description = Column(String, nullable=True) # Optional human-readable label
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class LocalUser(Base):
"""A locally-registered user authenticated by email and bcrypt password.
+44
View File
@@ -0,0 +1,44 @@
"""Celery task for asynchronous automation hook delivery with retry and backoff.
Uses :class:`~app.tasks.retry_config.BaseTaskWithRetry` so failed deliveries
are automatically retried with exponential backoff (default: 60 s, 300 s,
900 s) and ±20 % jitter.
"""
import logging
from typing import Any
from app.celery_app import celery
from app.tasks.retry_config import BaseTaskWithRetry
from app.utils.webhook import deliver_webhook
logger = logging.getLogger(__name__)
@celery.task(base=BaseTaskWithRetry, bind=True, name="automation.deliver_hook")
def deliver_automation_hook_task(self, url: str, payload: dict[str, Any], secret: str | None = None) -> dict[str, Any]:
"""Deliver an automation hook payload to *url* with automatic retries.
Args:
url: Target webhook URL (provided by Zapier / Make.com).
payload: The flat Zapier-compatible payload.
secret: Optional shared secret for HMAC-SHA256 signing.
Returns:
A dict with ``status`` and ``url`` on success.
Raises:
RuntimeError: Re-raised to trigger Celery retry on delivery failure.
"""
logger.info(
"Delivering automation hook to %s (attempt %d/%d)",
url,
self.request.retries + 1,
self.max_retries + 1,
)
success = deliver_webhook(url, payload, secret)
if success:
return {"status": "delivered", "url": url}
raise RuntimeError(f"Automation hook delivery to {url} failed")
+188
View File
@@ -0,0 +1,188 @@
"""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)
+12
View File
@@ -2050,6 +2050,18 @@ SETTING_METADATA = {
"required": False,
"restart_required": False,
},
"automation_hooks_enabled": {
"category": "Feature Flags",
"description": (
"Enable Zapier / Make.com automation hook subscriptions and delivery. "
"When enabled, external automation platforms can subscribe to DocuElevate events "
"via the REST hooks protocol. Default: True."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
"compliance_enabled": {
"category": "Feature Flags",
"description": (
+19 -10
View File
@@ -145,6 +145,8 @@ def dispatch_webhook_event(event: str, data: dict[str, Any]) -> None:
It delegates to :func:`deliver_webhook_task` (Celery) for each matching
webhook so delivery happens asynchronously with automatic retries.
Also dispatches to automation hooks (Zapier / Make.com) if enabled.
Args:
event: Event name (must be in :data:`VALID_EVENTS`).
data: Event-specific payload data.
@@ -156,16 +158,23 @@ def dispatch_webhook_event(event: str, data: dict[str, Any]) -> None:
webhooks = get_active_webhooks_for_event(event)
if not webhooks:
logger.debug("No active webhooks for event %s", event)
return
else:
payload = build_payload(event, data)
payload = build_payload(event, data)
# Import here to avoid circular dependency with celery_app
from app.tasks.webhook_tasks import deliver_webhook_task
# Import here to avoid circular dependency with celery_app
from app.tasks.webhook_tasks import deliver_webhook_task
for wh in webhooks:
try:
deliver_webhook_task.delay(wh["url"], payload, wh["secret"])
logger.debug("Queued webhook delivery to %s for event %s", wh["url"], event)
except Exception as exc:
logger.error("Failed to queue webhook to %s: %s", wh["url"], exc)
for wh in webhooks:
try:
deliver_webhook_task.delay(wh["url"], payload, wh["secret"])
logger.debug("Queued webhook delivery to %s for event %s", wh["url"], event)
except Exception as exc:
logger.error("Failed to queue webhook to %s: %s", wh["url"], exc)
# Also fan-out to Zapier / Make.com automation hooks
try:
from app.utils.automation_hooks import dispatch_automation_hooks
dispatch_automation_hooks(event, data)
except Exception as exc:
logger.error("Failed to dispatch automation hooks for event %s: %s", event, exc)
+153
View File
@@ -2499,6 +2499,159 @@ DELETE /api/classification-rules/{rule_id}
**Response:** `204 No Content`
## Automation (Zapier / Make.com)
Manage automation hook subscriptions for integrating DocuElevate with external platforms like Zapier and Make.com. All endpoints require API token authentication (`Authorization: Bearer <token>`).
### Supported Events
The automation system shares event types with the [Webhooks](#webhooks) subsystem:
| Event | Description |
|-------|-------------|
| `document.uploaded` | A new document has been ingested |
| `document.processed` | A document finished processing successfully |
| `document.failed` | Document processing failed |
| `user.signup` | A new user account was created |
| `user.plan_changed` | A user's subscription plan changed |
| `user.payment_issue` | A payment issue was reported for a user |
### GET /api/automation/events
List all valid event types that automation hooks can subscribe to.
**Response (200):**
```json
["document.failed", "document.processed", "document.uploaded", "user.payment_issue", "user.plan_changed", "user.signup"]
```
### POST /api/automation/hooks/subscribe
Subscribe to DocuElevate events. Zapier and Make.com call this endpoint to register a webhook URL that receives event notifications.
**Request:**
```bash
curl -X POST "http://your-instance/api/automation/hooks/subscribe" \
-H "Authorization: Bearer de_your_token_here" \
-H "Content-Type: application/json" \
-d '{
"target_url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/",
"events": ["document.processed", "document.uploaded"],
"hook_type": "zapier",
"secret": "optional-signing-secret",
"description": "My Zap for processed documents"
}'
```
**Response (201):**
```json
{
"id": 1,
"target_url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/",
"events": ["document.processed", "document.uploaded"],
"is_active": true,
"hook_type": "zapier",
"description": "My Zap for processed documents",
"has_secret": true
}
```
### GET /api/automation/hooks
List all automation hook subscriptions.
**Response (200):**
```json
[
{
"id": 1,
"target_url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/",
"events": ["document.processed", "document.uploaded"],
"is_active": true,
"hook_type": "zapier",
"description": "My Zap for processed documents",
"has_secret": true
}
]
```
### DELETE /api/automation/hooks/{hook_id}
Unsubscribe an automation hook. Zapier calls this when a Zap is turned off or deleted.
**Response (204):** No content.
### GET /api/automation/triggers/sample/{event}
Get sample trigger data for Zapier field mapping. Zapier uses this during Zap setup to discover available fields.
**Request:**
```bash
curl "http://your-instance/api/automation/triggers/sample/document.processed" \
-H "Authorization: Bearer de_your_token_here"
```
**Response (200):**
```json
[
{
"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"
}
]
```
### POST /api/automation/actions/upload
Upload a document from an automation platform. This incoming action endpoint allows Zapier or Make.com to push documents into DocuElevate for processing.
**Request:**
```bash
curl -X POST "http://your-instance/api/automation/actions/upload" \
-H "Authorization: Bearer de_your_token_here" \
-F "file=@/path/to/document.pdf"
```
**Response (200):**
```json
{
"status": "accepted",
"filename": "document.pdf",
"task_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
```
### Zapier-Compatible Payload Format
When events fire, automation hooks receive a **flat JSON payload** (no nested `data` key) that Zapier and Make.com can easily map:
```json
{
"id": "evt_a1b2c3d4e5f67890",
"event": "document.processed",
"timestamp": 1710000060.0,
"document_id": 42,
"filename": "invoice_2024.pdf",
"status": "processed",
"title": "Invoice #1234",
"owner_id": "user@example.com"
}
```
The `id` field is unique per event and is used by Zapier for deduplication. If a `secret` was provided during subscription, an `X-Webhook-Signature` header with an HMAC-SHA256 signature is included.
### Retry Behavior
Automation hook deliveries follow the same retry policy as regular webhooks: up to 3 retries with exponential backoff (60 s, 300 s, 900 s) and ±20% jitter.
## Further Assistance
For additional help with the API, please contact our support team or refer to the [Development Guide](../CONTRIBUTING.md).
+20
View File
@@ -1454,6 +1454,26 @@ Configurations are stored in the database and managed through the API (see [API
Webhook URLs, secrets, and subscribed events are configured per-webhook via the `/api/webhooks/` endpoints (admin access required). Each delivery includes an optional HMAC-SHA256 signature for verification and is retried with exponential backoff on failure.
### Automation Hooks (Zapier / Make.com)
Automation hooks enable integration with external automation platforms such as
[Zapier](https://zapier.com) and [Make.com](https://make.com) (formerly Integromat).
| **Variable** | **Description** | **Default** |
|----------------------------|------------------------------------------------------------------------------------------------|-------------|
| `AUTOMATION_HOOKS_ENABLED` | Enable or disable Zapier / Make.com automation hook subscriptions and delivery (`True`/`False`) | `True` |
When enabled, external platforms can:
- **Subscribe** to DocuElevate events via `POST /api/automation/hooks/subscribe` (outgoing triggers)
- **Send documents** to DocuElevate via `POST /api/automation/actions/upload` (incoming actions)
- **Discover fields** via `GET /api/automation/triggers/sample/{event}` (Zapier field mapping)
Automation hooks share the same event types as webhooks (`document.uploaded`, `document.processed`,
`document.failed`, `user.signup`, `user.plan_changed`, `user.payment_issue`) and use a flat
Zapier-compatible JSON payload format. See the [API docs](API.md#automation-zapier--makecom) for
endpoint details and payload examples.
### Backup & Restore
DocuElevate automatically backs up the database on a scheduled basis.
@@ -0,0 +1,39 @@
"""Add automation_hooks table for Zapier / Make.com webhook subscriptions.
Revision ID: 040_add_automation_hooks
Revises: 039_add_classification_rules
Create Date: 2026-03-09
"""
from typing import Union
import sqlalchemy as sa
from alembic import op
revision: str = "040_add_automation_hooks"
down_revision: Union[str, None] = "039_add_classification_rules"
depends_on: Union[str, None] = None
def upgrade() -> None:
"""Create automation_hooks table."""
op.create_table(
"automation_hooks",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("target_url", sa.String(), nullable=False),
sa.Column("secret", sa.String(), nullable=True),
sa.Column("events", sa.Text(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("hook_type", sa.String(50), nullable=False, server_default="generic"),
sa.Column("description", sa.String(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_automation_hooks_id", "automation_hooks", ["id"])
def downgrade() -> None:
"""Drop automation_hooks table."""
op.drop_index("ix_automation_hooks_id", "automation_hooks")
op.drop_table("automation_hooks")
+1
View File
@@ -62,6 +62,7 @@ from app.main import app as fastapi_app # noqa: E402
from app.models import ( # noqa: F401, E402
ApiToken,
AuditLog,
AutomationHook,
ClassificationRuleModel,
ComplianceTemplate,
DocumentMetadata,
+432
View File
@@ -0,0 +1,432 @@
"""Tests for the Zapier / Make.com automation integration.
Covers:
- Automation hook utility functions (payload builder, DB queries, dispatch)
- Automation hook Celery task
- REST hooks API endpoints (subscribe, unsubscribe, list, sample, events)
- Incoming action endpoints (upload)
- Integration with existing webhook dispatch
"""
import json
import time
from unittest.mock import MagicMock
import pytest
from app.models import AutomationHook
from app.utils.automation_hooks import (
SAMPLE_PAYLOADS,
build_zapier_payload,
dispatch_automation_hooks,
get_active_hooks_for_event,
)
from app.utils.webhook import VALID_EVENTS
# ---------------------------------------------------------------------------
# Unit tests build_zapier_payload
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestBuildZapierPayload:
"""Tests for the Zapier-compatible payload builder."""
def test_contains_required_keys(self):
"""Payload must contain id, event, timestamp, plus data fields."""
payload = build_zapier_payload("document.uploaded", {"document_id": 1})
assert "id" in payload
assert "event" in payload
assert "timestamp" in payload
assert "document_id" in payload
def test_id_starts_with_evt(self):
"""ID field must start with 'evt_' for Zapier deduplication."""
payload = build_zapier_payload("document.uploaded", {"document_id": 1})
assert payload["id"].startswith("evt_")
def test_event_matches_input(self):
"""Event field must match the event argument."""
payload = build_zapier_payload("document.processed", {"document_id": 2})
assert payload["event"] == "document.processed"
def test_timestamp_is_recent(self):
"""Timestamp should be close to current time."""
before = time.time()
payload = build_zapier_payload("document.uploaded", {})
after = time.time()
assert before <= payload["timestamp"] <= after
def test_data_is_flat(self):
"""Data fields should be merged into top level (flat, no nested 'data' key)."""
payload = build_zapier_payload("document.uploaded", {"filename": "test.pdf", "size": 1024})
assert payload["filename"] == "test.pdf"
assert payload["size"] == 1024
assert "data" not in payload
def test_unique_ids(self):
"""Each call should produce a unique ID."""
ids = {build_zapier_payload("document.uploaded", {})["id"] for _ in range(50)}
assert len(ids) == 50
# ---------------------------------------------------------------------------
# Unit tests SAMPLE_PAYLOADS
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSamplePayloads:
"""Tests for the sample payloads used by Zapier field mapping."""
def test_all_events_have_samples(self):
"""Every valid event should have a sample payload."""
for event in VALID_EVENTS:
assert event in SAMPLE_PAYLOADS, f"Missing sample payload for {event}"
def test_samples_contain_id_and_event(self):
"""Each sample should contain id and event keys."""
for event, sample in SAMPLE_PAYLOADS.items():
assert "id" in sample, f"Sample for {event} missing 'id'"
assert sample["event"] == event
# ---------------------------------------------------------------------------
# Unit tests get_active_hooks_for_event
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetActiveHooksForEvent:
"""Tests for querying active automation hooks from the database."""
def test_returns_matching_hooks(self, mocker):
"""Only hooks subscribed to the event should be returned."""
hook = MagicMock(
id=1,
target_url="https://hooks.zapier.com/1234",
secret="abc",
events=json.dumps(["document.uploaded"]),
is_active=True,
)
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.all.return_value = [hook]
mocker.patch("app.utils.automation_hooks.SessionLocal", return_value=mock_session)
result = get_active_hooks_for_event("document.uploaded")
assert len(result) == 1
assert result[0]["target_url"] == "https://hooks.zapier.com/1234"
def test_excludes_non_matching_hooks(self, mocker):
"""Hooks for different events should not be returned."""
hook = MagicMock(
id=1,
target_url="https://hooks.zapier.com/1234",
secret=None,
events=json.dumps(["document.processed"]),
is_active=True,
)
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.all.return_value = [hook]
mocker.patch("app.utils.automation_hooks.SessionLocal", return_value=mock_session)
result = get_active_hooks_for_event("document.uploaded")
assert len(result) == 0
def test_empty_when_no_hooks(self, mocker):
"""Empty list returned when no hooks exist."""
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.all.return_value = []
mocker.patch("app.utils.automation_hooks.SessionLocal", return_value=mock_session)
result = get_active_hooks_for_event("document.uploaded")
assert result == []
# ---------------------------------------------------------------------------
# Unit tests dispatch_automation_hooks
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDispatchAutomationHooks:
"""Tests for the automation hook dispatch function."""
def test_ignores_unknown_events(self, mocker):
"""Unknown events should be silently ignored."""
mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=True))
mock_get = mocker.patch("app.utils.automation_hooks.get_active_hooks_for_event")
dispatch_automation_hooks("bad.event", {})
mock_get.assert_not_called()
def test_skips_when_disabled(self, mocker):
"""No hooks should fire when automation_hooks_enabled is False."""
mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=False))
mock_get = mocker.patch("app.utils.automation_hooks.get_active_hooks_for_event")
dispatch_automation_hooks("document.uploaded", {"file_id": 1})
mock_get.assert_not_called()
def test_queues_celery_task_for_each_hook(self, mocker):
"""A Celery task is queued for each matching hook."""
mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=True))
mocker.patch(
"app.utils.automation_hooks.get_active_hooks_for_event",
return_value=[
{"id": 1, "target_url": "https://hooks.zapier.com/a", "secret": "s", "events": ["document.uploaded"]},
{"id": 2, "target_url": "https://hooks.zapier.com/b", "secret": None, "events": ["document.uploaded"]},
],
)
mock_task = mocker.patch("app.tasks.automation_tasks.deliver_automation_hook_task.delay")
dispatch_automation_hooks("document.uploaded", {"file_id": 42})
assert mock_task.call_count == 2
def test_no_tasks_when_no_hooks(self, mocker):
"""No tasks should be queued when there are no matching hooks."""
mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=True))
mocker.patch("app.utils.automation_hooks.get_active_hooks_for_event", return_value=[])
mock_task = mocker.patch("app.tasks.automation_tasks.deliver_automation_hook_task.delay")
dispatch_automation_hooks("document.uploaded", {})
mock_task.assert_not_called()
# ---------------------------------------------------------------------------
# Unit tests deliver_automation_hook_task
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDeliverAutomationHookTask:
"""Tests for the automation hook Celery task."""
def test_returns_success_dict(self, mocker):
"""Successful delivery returns status dict."""
mocker.patch("app.tasks.automation_tasks.deliver_webhook", return_value=True)
from app.tasks.automation_tasks import deliver_automation_hook_task
deliver_automation_hook_task.request.retries = 0
result = deliver_automation_hook_task.__wrapped__("https://hooks.zapier.com/test", {"event": "test"}, None)
assert result["status"] == "delivered"
assert result["url"] == "https://hooks.zapier.com/test"
def test_raises_on_failure(self, mocker):
"""Failed delivery raises RuntimeError for Celery retry."""
mocker.patch("app.tasks.automation_tasks.deliver_webhook", return_value=False)
from app.tasks.automation_tasks import deliver_automation_hook_task
deliver_automation_hook_task.request.retries = 0
with pytest.raises(RuntimeError, match="Automation hook delivery"):
deliver_automation_hook_task.__wrapped__("https://hooks.zapier.com/test", {"event": "test"}, None)
# ---------------------------------------------------------------------------
# Integration tests webhook dispatch integration
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestWebhookDispatchIntegration:
"""Test that dispatch_webhook_event also triggers automation hooks."""
def test_dispatch_triggers_automation_hooks(self, mocker):
"""dispatch_webhook_event should also call dispatch_automation_hooks."""
mocker.patch("app.utils.webhook.get_active_webhooks_for_event", return_value=[])
mock_auto = mocker.patch("app.utils.automation_hooks.dispatch_automation_hooks")
from app.utils.webhook import dispatch_webhook_event
dispatch_webhook_event("document.uploaded", {"file_id": 1})
mock_auto.assert_called_once_with("document.uploaded", {"file_id": 1})
# ---------------------------------------------------------------------------
# Integration tests API endpoints
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestAutomationAPI:
"""Tests for the /api/automation/ endpoints."""
def _with_auth(self, client):
"""Override auth dependency to simulate an authenticated user."""
from app.api.automation import _require_api_user
client.app.dependency_overrides[_require_api_user] = lambda: {
"id": "testuser",
"email": "test@example.com",
"preferred_username": "testuser",
"is_admin": False,
}
return client
# ── Subscribe / Unsubscribe ──────────────────────────────────────
def test_subscribe_hook(self, client):
"""POST /api/automation/hooks/subscribe creates a new hook."""
self._with_auth(client)
resp = client.post(
"/api/automation/hooks/subscribe",
json={
"target_url": "https://hooks.zapier.com/test",
"events": ["document.uploaded"],
"hook_type": "zapier",
"description": "My Zap",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["target_url"] == "https://hooks.zapier.com/test"
assert data["events"] == ["document.uploaded"]
assert data["is_active"] is True
assert data["hook_type"] == "zapier"
def test_subscribe_with_secret(self, client):
"""POST /api/automation/hooks/subscribe with secret masks it."""
self._with_auth(client)
resp = client.post(
"/api/automation/hooks/subscribe",
json={
"target_url": "https://hooks.zapier.com/secret",
"events": ["document.processed"],
"secret": "my-signing-secret",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["has_secret"] is True
assert "secret" not in data
def test_subscribe_invalid_event(self, client):
"""POST /api/automation/hooks/subscribe rejects invalid events."""
self._with_auth(client)
resp = client.post(
"/api/automation/hooks/subscribe",
json={
"target_url": "https://hooks.zapier.com/bad",
"events": ["bad.event"],
},
)
assert resp.status_code == 422
def test_unsubscribe_hook(self, client, db_session):
"""DELETE /api/automation/hooks/{id} removes the hook."""
self._with_auth(client)
hook = AutomationHook(
target_url="https://hooks.zapier.com/del",
events=json.dumps(["document.uploaded"]),
is_active=True,
hook_type="zapier",
)
db_session.add(hook)
db_session.commit()
hook_id = hook.id
resp = client.delete(f"/api/automation/hooks/{hook_id}")
assert resp.status_code == 204
def test_unsubscribe_not_found(self, client):
"""DELETE /api/automation/hooks/9999 returns 404."""
self._with_auth(client)
resp = client.delete("/api/automation/hooks/9999")
assert resp.status_code == 404
# ── List hooks ───────────────────────────────────────────────────
def test_list_hooks(self, client, db_session):
"""GET /api/automation/hooks returns all hooks."""
self._with_auth(client)
hook = AutomationHook(
target_url="https://hooks.zapier.com/list",
events=json.dumps(["document.processed"]),
is_active=True,
hook_type="make",
)
db_session.add(hook)
db_session.commit()
resp = client.get("/api/automation/hooks")
assert resp.status_code == 200
items = resp.json()
assert any(h["target_url"] == "https://hooks.zapier.com/list" for h in items)
# ── Sample trigger data ──────────────────────────────────────────
def test_trigger_sample(self, client):
"""GET /api/automation/triggers/sample/{event} returns sample data."""
self._with_auth(client)
resp = client.get("/api/automation/triggers/sample/document.uploaded")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) == 1
assert data[0]["event"] == "document.uploaded"
assert "id" in data[0]
def test_trigger_sample_unknown_event(self, client):
"""GET /api/automation/triggers/sample/bad returns 404."""
self._with_auth(client)
resp = client.get("/api/automation/triggers/sample/bad.event")
assert resp.status_code == 404
# ── Events listing ───────────────────────────────────────────────
def test_list_events(self, client):
"""GET /api/automation/events returns valid event types."""
self._with_auth(client)
resp = client.get("/api/automation/events")
assert resp.status_code == 200
events = resp.json()
assert "document.uploaded" in events
assert "document.processed" in events
assert "document.failed" in events
# ── Incoming action: upload ──────────────────────────────────────
def test_action_upload(self, client, mocker):
"""POST /api/automation/actions/upload accepts a file."""
self._with_auth(client)
mock_task = MagicMock()
mock_task.id = "task-123"
mocker.patch("app.tasks.process_document.process_document.delay", return_value=mock_task)
resp = client.post(
"/api/automation/actions/upload",
files={"file": ("test.pdf", b"fake-pdf-content", "application/pdf")},
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "accepted"
assert data["filename"] == "test.pdf"
assert data["task_id"] == "task-123"
def test_action_upload_no_filename(self, client):
"""POST /api/automation/actions/upload rejects empty filename."""
self._with_auth(client)
resp = client.post(
"/api/automation/actions/upload",
files={"file": ("", b"content", "application/pdf")},
)
# FastAPI/Starlette may return 422 (multipart validation) or 400
# (our explicit check) depending on how the empty filename is
# parsed by the underlying multipart parser version.
assert resp.status_code in (400, 422)
# ── Auth required ────────────────────────────────────────────────
def test_requires_auth(self, client):
"""Endpoints return 401 without authentication."""
from app.api.automation import _require_api_user
client.app.dependency_overrides.pop(_require_api_user, None)
resp = client.get("/api/automation/hooks")
assert resp.status_code == 401
+5
View File
@@ -38,6 +38,11 @@ class TestConvertPdfToPdfa:
assert "pdfa-2" in cmd
assert "--quiet" in cmd
assert "--invalidate-digital-signatures" in cmd
# SECURITY: Verify `--` end-of-options separator is present and precedes
# the file paths to prevent option/argument injection.
assert "--" in cmd
assert cmd.index("--") < cmd.index("/input.pdf")
assert cmd.index("--") < cmd.index("/output.pdf")
assert "/input.pdf" in cmd
assert "/output.pdf" in cmd
+4 -6
View File
@@ -769,13 +769,11 @@ class TestUploadRclone:
cmd = mock_run.call_args[0][0]
assert cmd[0] == "rclone"
assert cmd[1] == "copyto"
# Verify the -- end-of-options separator is present and precedes the positional
# file arguments, preventing any file path starting with '-' from being
# misinterpreted as a flag (security hardening regression guard).
# SECURITY: Verify `--` end-of-options separator is present and precedes
# the file path and destination to prevent option/argument injection.
assert "--" in cmd
separator_idx = cmd.index("--")
file_idx = cmd.index(fp)
assert separator_idx < file_idx, "'--' must appear before the file_path argument"
fp_index = next(i for i, v in enumerate(cmd) if v == fp)
assert cmd.index("--") < fp_index
def test_raises_on_rclone_nonzero_exit(self, tmp_path):
fp = str(tmp_path / "doc.pdf")