Files
saas_backend/app/models/system/webhook_model.py
T

101 lines
3.9 KiB
Python
Raw Normal View History

2026-08-31 20:04:12 -04:00
"""A customer's own endpoint, and what has been sent to it."""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
String,
func,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import relationship
from app.config.database import Base
class WebhookEndpoint(Base):
__tablename__ = "webhook_endpoints"
id = Column(UUID(as_uuid=True), primary_key=True,
server_default=func.gen_random_uuid())
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False)
url = Column(String(2048), nullable=False)
description = Column(String(255))
event_types = Column(JSONB, nullable=False, default=list)
secret_enc = Column(String, nullable=False)
is_active = Column(Boolean, nullable=False, default=True)
disabled_reason = Column(String(255))
consecutive_failures = Column(Integer, nullable=False, default=0)
last_success_at = Column(DateTime(timezone=True))
last_failure_at = Column(DateTime(timezone=True))
created_by_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
deliveries = relationship("WebhookDelivery", back_populates="endpoint",
lazy="raise", cascade="all, delete-orphan")
@property
def secret(self) -> str | None:
"""Encrypted, not hashed — unlike an API key.
The customer has to configure this same value in their receiver to check
signatures, so the platform genuinely needs to be able to hand it back.
Storing it reversibly is a real cost; the alternative is a secret nobody
can use, which is not a security property, only an obstacle.
"""
from app.core.crypto import decrypt_json, is_encrypted
if not self.secret_enc:
return None
if is_encrypted(self.secret_enc):
return (decrypt_json(self.secret_enc) or {}).get("secret")
return None
@secret.setter
def secret(self, value: str | None) -> None:
from app.core.crypto import encrypt_json
self.secret_enc = encrypt_json({"secret": value}) if value else None
def wants(self, event_type: str) -> bool:
"""Empty means everything.
The first endpoint a workspace registers is usually "send me what you
have" — making them enumerate the catalogue before anything arrives is
how a setup gets abandoned halfway.
"""
wanted = list(self.event_types or [])
return not wanted or event_type in wanted
class WebhookDelivery(Base):
__tablename__ = "webhook_deliveries"
id = Column(UUID(as_uuid=True), primary_key=True,
server_default=func.gen_random_uuid())
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False)
endpoint_id = Column(UUID(as_uuid=True),
ForeignKey("webhook_endpoints.id", ondelete="CASCADE"),
nullable=False)
event_id = Column(UUID(as_uuid=True), nullable=False)
event_type = Column(String(120), nullable=False)
payload = Column(JSONB, nullable=False)
status = Column(String(20), nullable=False, default="pending")
attempts = Column(Integer, nullable=False, default=0)
next_attempt_at = Column(DateTime(timezone=True), nullable=False,
default=lambda: datetime.now(timezone.utc))
response_status = Column(Integer)
error = Column(String(1000))
delivered_at = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
endpoint = relationship("WebhookEndpoint", back_populates="deliveries", lazy="raise")