diff --git a/backend/alembic/versions/b031_phase6_webhooks.py b/backend/alembic/versions/b031_phase6_webhooks.py new file mode 100644 index 0000000..38e1709 --- /dev/null +++ b/backend/alembic/versions/b031_phase6_webhooks.py @@ -0,0 +1,32 @@ +"""Phase 6.1: webhook_configs table. + +Revision ID: b031phase6 +Revises: b030phase5 +""" +from typing import Sequence, Union +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from alembic import op + +revision: str = "b031phase6" +down_revision: Union[str, None] = "b030phase5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +def upgrade() -> None: + op.create_table( + "webhook_configs", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("url", sa.Text, nullable=False), + sa.Column("secret", sa.String(256), nullable=True), + sa.Column("events", postgresql.JSONB, nullable=False, server_default="[]"), + sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"), + sa.Column("created_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("last_triggered_at", sa.DateTime, nullable=True), + sa.Column("failure_count", sa.Integer, nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + +def downgrade() -> None: + op.drop_table("webhook_configs") diff --git a/backend/alembic/versions/b032_phase7_user_prefs.py b/backend/alembic/versions/b032_phase7_user_prefs.py new file mode 100644 index 0000000..2859994 --- /dev/null +++ b/backend/alembic/versions/b032_phase7_user_prefs.py @@ -0,0 +1,41 @@ +"""Phase 7.2: user notification_preferences and jira_account_id columns. + +Revision ID: b032phase7 +Revises: b031phase6 +""" +from typing import Sequence, Union +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from alembic import op + +revision: str = "b032phase7" +down_revision: Union[str, None] = "b031phase6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_DEFAULT_PREFS = '{"email_on_test_validated": true, "email_on_campaign_completed": true, "email_on_new_mitre_techniques": false, "in_app_all": true}' + +def _column_names(table: str) -> set[str]: + bind = op.get_bind() + insp = sa.inspect(bind) + return {c["name"] for c in insp.get_columns(table)} + +def upgrade() -> None: + user_cols = _column_names("users") + if "notification_preferences" not in user_cols: + op.add_column( + "users", + sa.Column("notification_preferences", postgresql.JSONB, nullable=True, server_default=_DEFAULT_PREFS), + ) + if "jira_account_id" not in user_cols: + op.add_column( + "users", + sa.Column("jira_account_id", sa.String(100), nullable=True), + ) + +def downgrade() -> None: + user_cols = _column_names("users") + if "jira_account_id" in user_cols: + op.drop_column("users", "jira_account_id") + if "notification_preferences" in user_cols: + op.drop_column("users", "notification_preferences") diff --git a/backend/app/config.py b/backend/app/config.py index 85f2c14..8dfb3b6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -70,6 +70,16 @@ class Settings(BaseSettings): COMPANY_NAME: str = "Organization" COMPANY_LOGO_PATH: str = "app/templates/reports/assets/logo.png" + # ── Email / SMTP ────────────────────────────────────────────────── + SMTP_ENABLED: bool = False + SMTP_HOST: str = "" + SMTP_PORT: int = 587 + SMTP_USERNAME: str = "" + SMTP_PASSWORD: str = "" + SMTP_FROM_EMAIL: str = "aegis@company.com" + SMTP_USE_TLS: bool = True + PLATFORM_URL: str = "http://localhost:5173" # base URL for links in emails + # ── Scoring weights (must sum to 100) ──────────────────────────── SCORING_WEIGHT_TESTS: int = 40 SCORING_WEIGHT_DETECTION_RULES: int = 25 diff --git a/backend/app/jobs/mitre_sync_job.py b/backend/app/jobs/mitre_sync_job.py index d3f96ab..94df5d5 100644 --- a/backend/app/jobs/mitre_sync_job.py +++ b/backend/app/jobs/mitre_sync_job.py @@ -42,11 +42,13 @@ scheduler = BackgroundScheduler() def _run_mitre_sync() -> None: """Execute a MITRE sync inside its own DB session.""" + from app.services.webhook_service import dispatch_webhook logger.info("Scheduled MITRE sync job starting...") db = SessionLocal() try: summary = sync_mitre(db) logger.info("Scheduled MITRE sync job finished — %s", summary) + dispatch_webhook("mitre.synced", {"created": summary.get("created", 0), "updated": summary.get("updated", 0)}) except Exception: logger.exception("Scheduled MITRE sync job failed") finally: diff --git a/backend/app/main.py b/backend/app/main.py index c67b735..73abe12 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -37,6 +37,7 @@ from app.routers import professional_reports as professional_reports_router from app.routers import analytics as analytics_router from app.routers import advanced_metrics as advanced_metrics_router from app.routers import osint as osint_router +from app.routers import webhooks as webhooks_router from app.domain.errors import DomainError from app.middleware.error_handler import domain_exception_handler from app.middleware.request_context import RequestContextMiddleware @@ -123,6 +124,7 @@ app.include_router(professional_reports_router.router, prefix="/api/v1") app.include_router(analytics_router.router, prefix="/api/v1") app.include_router(advanced_metrics_router.router, prefix="/api/v1") app.include_router(osint_router.router, prefix="/api/v1") +app.include_router(webhooks_router.router, prefix="/api/v1") @app.get("/health", include_in_schema=False) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index cb0324b..b178668 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,6 +1,6 @@ import uuid from sqlalchemy import Column, String, Boolean, DateTime, func -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.dialects.postgresql import UUID, JSONB from app.database import Base @@ -28,3 +28,5 @@ class User(Base): must_change_password = Column(Boolean, default=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) last_login = Column(DateTime, nullable=True) + notification_preferences = Column(JSONB, nullable=True, server_default='{"email_on_test_validated": true, "email_on_campaign_completed": true, "email_on_new_mitre_techniques": false, "in_app_all": true}') + jira_account_id = Column(String(100), nullable=True) diff --git a/backend/app/models/webhook_config.py b/backend/app/models/webhook_config.py new file mode 100644 index 0000000..d9d8543 --- /dev/null +++ b/backend/app/models/webhook_config.py @@ -0,0 +1,19 @@ +"""WebhookConfig model — outbound HTTP notification endpoints.""" +import uuid +from datetime import datetime +from sqlalchemy import Column, String, Boolean, DateTime, Integer, Text, ForeignKey, func +from sqlalchemy.dialects.postgresql import UUID, JSONB +from app.database import Base + +class WebhookConfig(Base): + __tablename__ = "webhook_configs" + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(String(200), nullable=False) + url = Column(Text, nullable=False) + secret = Column(String(256), nullable=True) # HMAC signature key + events = Column(JSONB, nullable=False, server_default="[]") # list of event types + is_active = Column(Boolean, default=True, nullable=False) + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + last_triggered_at = Column(DateTime, nullable=True) + failure_count = Column(Integer, default=0, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/routers/campaigns.py b/backend/app/routers/campaigns.py index 7eeeb61..59a2882 100644 --- a/backend/app/routers/campaigns.py +++ b/backend/app/routers/campaigns.py @@ -33,6 +33,7 @@ from app.services.campaign_crud_service import ( from app.domain.unit_of_work import UnitOfWork from app.services.audit_service import log_action from app.services.notification_service import notify_role +from app.services.webhook_service import dispatch_webhook logger = logging.getLogger(__name__) @@ -284,6 +285,7 @@ def complete_campaign( ) uow.commit() db.refresh(campaign) + dispatch_webhook("campaign.completed", {"campaign_id": str(campaign.id), "name": campaign.name}) return serialize_campaign(db, campaign) diff --git a/backend/app/routers/tests.py b/backend/app/routers/tests.py index 061f68a..e5ee591 100644 --- a/backend/app/routers/tests.py +++ b/backend/app/routers/tests.py @@ -45,6 +45,7 @@ from app.schemas.test_template import TestTemplateInstantiate from app.domain.unit_of_work import UnitOfWork from app.services.audit_service import log_action from app.services.status_service import recalculate_technique_status +from app.services.webhook_service import dispatch_webhook from app.services.test_crud_service import ( create_test as crud_create_test, create_test_from_template as crud_create_from_template, @@ -462,6 +463,10 @@ def validate_red( recalculate_technique_status(db, test.technique) uow.commit() db.refresh(test) + if test.state == TestState.validated: + dispatch_webhook("test.validated", {"test_id": str(test.id), "technique_id": str(test.technique_id), "result": test.result.value if test.result else None}) + elif test.state == TestState.rejected: + dispatch_webhook("test.rejected", {"test_id": str(test.id), "technique_id": str(test.technique_id)}) return test @@ -489,6 +494,10 @@ def validate_blue( recalculate_technique_status(db, test.technique) uow.commit() db.refresh(test) + if test.state == TestState.validated: + dispatch_webhook("test.validated", {"test_id": str(test.id), "technique_id": str(test.technique_id), "result": test.result.value if test.result else None}) + elif test.state == TestState.rejected: + dispatch_webhook("test.rejected", {"test_id": str(test.id), "technique_id": str(test.technique_id)}) return test diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index c9e96cd..b841f65 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -9,7 +9,8 @@ from app.database import get_db from app.dependencies.auth import require_role from app.domain.unit_of_work import UnitOfWork from app.models.user import User -from app.schemas.user import UserCreate, UserUpdate, UserOut +from app.dependencies.auth import get_current_user +from app.schemas.user import UserCreate, UserUpdate, UserOut, UserPreferencesUpdate from app.services.audit_service import log_action from app.services.user_service import ( create_user, @@ -21,6 +22,26 @@ from app.services.user_service import ( router = APIRouter(prefix="/users", tags=["users"]) +# --------------------------------------------------------------------------- +# PATCH /users/me/preferences — update current user preferences +# --------------------------------------------------------------------------- + + +@router.patch("/me/preferences", response_model=UserOut) +def update_my_preferences( + payload: UserPreferencesUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update the current user's notification preferences and Jira account ID.""" + update_data = payload.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(current_user, field, value) + db.commit() + db.refresh(current_user) + return current_user + + # --------------------------------------------------------------------------- # GET /users — list all users # --------------------------------------------------------------------------- diff --git a/backend/app/routers/webhooks.py b/backend/app/routers/webhooks.py new file mode 100644 index 0000000..5a9e39c --- /dev/null +++ b/backend/app/routers/webhooks.py @@ -0,0 +1,149 @@ +"""Webhook configuration CRUD router — admin only. + +Endpoints +--------- +GET /webhooks — list all webhook configs +POST /webhooks — create a new webhook config +GET /webhooks/{id} — get a single webhook config +PATCH /webhooks/{id} — update a webhook config +DELETE /webhooks/{id} — hard-delete a webhook config +POST /webhooks/{id}/test — send a test ping +""" + +import uuid + +from fastapi import APIRouter, Depends, status +from sqlalchemy.orm import Session + +from app.database import get_db +from app.dependencies.auth import require_role +from app.domain.unit_of_work import UnitOfWork +from app.models.user import User +from app.schemas.webhook import WebhookConfigCreate, WebhookConfigOut, WebhookConfigUpdate +from app.services.webhook_service import ( + create_webhook, + delete_webhook, + dispatch_webhook, + get_webhook_or_raise, + list_webhooks, + update_webhook, +) + +router = APIRouter(prefix="/webhooks", tags=["webhooks"]) + + +def _mask_secret(wh) -> WebhookConfigOut: + """Return a WebhookConfigOut with the secret masked.""" + out = WebhookConfigOut.model_validate(wh) + if wh.secret: + out.secret = "***" + else: + out.secret = None + return out + + +# --------------------------------------------------------------------------- +# GET /webhooks +# --------------------------------------------------------------------------- + + +@router.get("", response_model=list[WebhookConfigOut]) +def list_webhooks_route( + offset: int = 0, + limit: int = 50, + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Return all webhook configurations. **Requires admin role.**""" + webhooks = list_webhooks(db, offset=offset, limit=limit) + return [_mask_secret(wh) for wh in webhooks] + + +# --------------------------------------------------------------------------- +# POST /webhooks +# --------------------------------------------------------------------------- + + +@router.post("", response_model=WebhookConfigOut, status_code=status.HTTP_201_CREATED) +def create_webhook_route( + payload: WebhookConfigCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Create a new webhook configuration. **Requires admin role.**""" + with UnitOfWork(db) as uow: + wh = create_webhook(db, created_by=current_user.id, payload=payload) + uow.commit() + db.refresh(wh) + return _mask_secret(wh) + + +# --------------------------------------------------------------------------- +# GET /webhooks/{id} +# --------------------------------------------------------------------------- + + +@router.get("/{webhook_id}", response_model=WebhookConfigOut) +def get_webhook_route( + webhook_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Return a single webhook configuration. **Requires admin role.**""" + wh = get_webhook_or_raise(db, webhook_id) + return _mask_secret(wh) + + +# --------------------------------------------------------------------------- +# PATCH /webhooks/{id} +# --------------------------------------------------------------------------- + + +@router.patch("/{webhook_id}", response_model=WebhookConfigOut) +def update_webhook_route( + webhook_id: uuid.UUID, + payload: WebhookConfigUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Update one or more fields of a webhook configuration. **Requires admin role.**""" + with UnitOfWork(db) as uow: + wh = update_webhook(db, webhook_id, payload) + uow.commit() + db.refresh(wh) + return _mask_secret(wh) + + +# --------------------------------------------------------------------------- +# DELETE /webhooks/{id} +# --------------------------------------------------------------------------- + + +@router.delete("/{webhook_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_webhook_route( + webhook_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Hard-delete a webhook configuration. **Requires admin role.**""" + with UnitOfWork(db) as uow: + delete_webhook(db, webhook_id) + uow.commit() + + +# --------------------------------------------------------------------------- +# POST /webhooks/{id}/test +# --------------------------------------------------------------------------- + + +@router.post("/{webhook_id}/test", status_code=status.HTTP_202_ACCEPTED) +def test_webhook_route( + webhook_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Send a test ping to the webhook endpoint. **Requires admin role.**""" + # Verify the webhook exists before dispatching + get_webhook_or_raise(db, webhook_id) + dispatch_webhook("webhook.test", {"webhook_id": str(webhook_id), "message": "Test ping from Aegis"}) + return {"detail": "Test ping dispatched"} diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 7d89d61..60cbe3e 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -121,6 +121,13 @@ class PasswordChange(BaseModel): return _validate_password_strength(v) +class UserPreferencesUpdate(BaseModel): + """Payload for updating current user's notification preferences and Jira account.""" + + notification_preferences: dict | None = None + jira_account_id: str | None = None + + class UserOut(BaseModel): """Complete representation returned by the API.""" @@ -132,5 +139,7 @@ class UserOut(BaseModel): must_change_password: bool = True created_at: datetime | None = None last_login: datetime | None = None + notification_preferences: dict | None = None + jira_account_id: str | None = None model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/webhook.py b/backend/app/schemas/webhook.py new file mode 100644 index 0000000..701c358 --- /dev/null +++ b/backend/app/schemas/webhook.py @@ -0,0 +1,32 @@ +"""Pydantic schemas for Webhook endpoints.""" +import uuid +from datetime import datetime +from typing import Any +from pydantic import BaseModel, HttpUrl, ConfigDict + +class WebhookConfigCreate(BaseModel): + name: str + url: str + secret: str | None = None + events: list[str] = [] + is_active: bool = True + +class WebhookConfigUpdate(BaseModel): + name: str | None = None + url: str | None = None + secret: str | None = None + events: list[str] | None = None + is_active: bool | None = None + +class WebhookConfigOut(BaseModel): + id: uuid.UUID + name: str + url: str + secret: str | None = None # masked on read + events: list[str] + is_active: bool + created_by: uuid.UUID | None = None + last_triggered_at: datetime | None = None + failure_count: int + created_at: datetime | None = None + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..44c68f8 --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,79 @@ +"""Email notification service using SMTP. + +Sending is silently skipped when SMTP_ENABLED=False (default). +All errors are caught and logged — email failures never crash the caller. +""" +import logging +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from app.config import settings + +logger = logging.getLogger(__name__) + + +def send_email(to: str, subject: str, html_body: str) -> bool: + """Send an HTML email. Returns True on success, False on skip/error.""" + if not settings.SMTP_ENABLED: + logger.debug("SMTP disabled — skipping email to %s: %s", to, subject) + return False + if not to: + return False + try: + msg = MIMEMultipart("alternative") + msg["Subject"] = f"[Aegis] {subject}" + msg["From"] = settings.SMTP_FROM_EMAIL + msg["To"] = to + msg.attach(MIMEText(html_body, "html")) + with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=10) as server: + if settings.SMTP_USE_TLS: + server.starttls() + if settings.SMTP_USERNAME: + server.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD) + server.send_message(msg) + logger.info("Email sent to %s: %s", to, subject) + return True + except Exception: + logger.exception("Failed to send email to %s: %s", to, subject) + return False + + +def send_test_validated_email(to: str, test_name: str, technique_id: str, test_id: str) -> bool: + """Notify that a test was validated.""" + url = f"{settings.PLATFORM_URL}/tests/{test_id}" + html = f""" + +

✅ Test Validated

+

Test {test_name} for technique {technique_id} has been validated.

+

View Test

+

Aegis ATT&CK Coverage Platform

+ """ + return send_email(to, f"Test Validated: {test_name}", html) + + +def send_campaign_completed_email(to: str, campaign_name: str, campaign_id: str) -> bool: + """Notify that a campaign was completed.""" + url = f"{settings.PLATFORM_URL}/campaigns/{campaign_id}" + html = f""" + +

🎯 Campaign Completed

+

Campaign {campaign_name} has been completed.

+

View Campaign

+

Aegis ATT&CK Coverage Platform

+ """ + return send_email(to, f"Campaign Completed: {campaign_name}", html) + + +def send_new_mitre_techniques_email(to: str, created: int, updated: int) -> bool: + """Notify of new MITRE techniques after sync.""" + if created == 0: + return False + html = f""" + +

🔄 MITRE ATT&CK Updated

+

{created} new techniques added, {updated} updated.

+

View Techniques

+

Aegis ATT&CK Coverage Platform

+ """ + return send_email(to, f"MITRE ATT&CK Updated: {created} new techniques", html) diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 4838468..b78e7cc 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -158,6 +158,40 @@ def cleanup_old_notifications(db: Session, days: int = 90) -> int: # --------------------------------------------------------------------------- +def notify_role_with_email( + db: Session, + *, + role: str, + type: str, + title: str, + message: str, + entity_type: str, + entity_id: uuid.UUID, + email_fn=None, # callable(user_email) -> bool, optional +) -> None: + """Send in-app notifications + optional email to all active users with a role.""" + users = ( + db.query(User) + .filter(User.role == role, User.is_active == True) # noqa: E712 + .all() + ) + for user in users: + create_notification( + db, + user_id=user.id, + type=type, + title=title, + message=message, + entity_type=entity_type, + entity_id=entity_id, + ) + if email_fn and user.email: + try: + email_fn(user.email) + except Exception: + pass # email failures never crash notification flow + + def notify_test_state_change(db: Session, test, new_state: str) -> None: """Dispatch notifications based on a test's new state. diff --git a/backend/app/services/webhook_service.py b/backend/app/services/webhook_service.py new file mode 100644 index 0000000..8e199b6 --- /dev/null +++ b/backend/app/services/webhook_service.py @@ -0,0 +1,145 @@ +"""Webhook dispatch service — outbound HTTP notifications. + +Supported event types: +- test.validated — fired when a test reaches "validated" state +- test.rejected — fired when a test reaches "rejected" state +- campaign.completed — fired when a campaign is completed +- campaign.started — fired when a campaign is activated +- mitre.synced — fired after MITRE ATT&CK sync completes +- technique.status_changed — fired when a technique's status changes +- webhook.test — manual test ping from the admin UI +""" + +import hashlib +import hmac +import logging +import uuid +from datetime import datetime + +import requests + +from app.database import SessionLocal +from app.models.webhook_config import WebhookConfig + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- + + +def dispatch_webhook(event_type: str, payload: dict) -> None: + """Send an outbound webhook to all active subscribers for *event_type*. + + Opens its own DB session so this can be called outside request context + (e.g. from background jobs). All exceptions are caught; webhook failures + never crash the caller. + """ + db = SessionLocal() + try: + webhooks = ( + db.query(WebhookConfig) + .filter(WebhookConfig.is_active == True) # noqa: E712 + .all() + ) + for wh in webhooks: + # Filter by subscribed events — empty list means "all events" + subscribed: list = wh.events or [] + if subscribed and event_type not in subscribed: + continue + _send_webhook(db, wh, event_type, payload) + except Exception: + logger.exception("dispatch_webhook: unexpected error for event_type=%s", event_type) + finally: + db.close() + + +def _send_webhook(db, wh: WebhookConfig, event_type: str, payload: dict) -> None: + """Send a single webhook POST and update its metadata.""" + body = { + "event": event_type, + "data": payload, + "timestamp": datetime.utcnow().isoformat() + "Z", + } + headers = {"Content-Type": "application/json"} + if wh.secret: + import json + raw = json.dumps(body, separators=(",", ":"), sort_keys=True).encode() + sig = hmac.new(wh.secret.encode(), raw, hashlib.sha256).hexdigest() + headers["X-Aegis-Signature"] = f"sha256={sig}" + try: + resp = requests.post(wh.url, json=body, headers=headers, timeout=10) + resp.raise_for_status() + wh.last_triggered_at = datetime.utcnow() + wh.failure_count = 0 + db.commit() + logger.info("Webhook '%s' (%s) dispatched OK for event=%s", wh.name, wh.url, event_type) + except Exception as exc: + wh.failure_count = (wh.failure_count or 0) + 1 + wh.last_triggered_at = datetime.utcnow() + try: + db.commit() + except Exception: + db.rollback() + logger.warning( + "Webhook '%s' (%s) failed for event=%s: %s (failure_count=%d)", + wh.name, wh.url, event_type, exc, wh.failure_count, + ) + + +# --------------------------------------------------------------------------- +# CRUD +# --------------------------------------------------------------------------- + + +def list_webhooks(db, *, offset: int = 0, limit: int = 50) -> list[WebhookConfig]: + """Return paginated webhook configs.""" + return ( + db.query(WebhookConfig) + .order_by(WebhookConfig.created_at.desc()) + .offset(offset) + .limit(limit) + .all() + ) + + +def get_webhook_or_raise(db, webhook_id: uuid.UUID) -> WebhookConfig: + """Fetch a webhook by ID or raise 404.""" + from app.domain.errors import EntityNotFoundError + wh = db.query(WebhookConfig).filter(WebhookConfig.id == webhook_id).first() + if wh is None: + raise EntityNotFoundError("WebhookConfig", str(webhook_id)) + return wh + + +def create_webhook(db, created_by: uuid.UUID, payload) -> WebhookConfig: + """Create and persist a new WebhookConfig.""" + wh = WebhookConfig( + name=payload.name, + url=payload.url, + secret=payload.secret, + events=payload.events, + is_active=payload.is_active, + created_by=created_by, + ) + db.add(wh) + db.flush() + return wh + + +def update_webhook(db, webhook_id: uuid.UUID, payload) -> WebhookConfig: + """Apply a partial update to an existing WebhookConfig.""" + wh = get_webhook_or_raise(db, webhook_id) + update_data = payload.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(wh, field, value) + db.flush() + return wh + + +def delete_webhook(db, webhook_id: uuid.UUID) -> None: + """Hard-delete a WebhookConfig.""" + wh = get_webhook_or_raise(db, webhook_id) + db.delete(wh) + db.flush()