"""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")