import json import secrets from datetime import datetime import asyncpg from utils.read.models import DeliveryView, WebhookView STATUS_PENDING = "pending" STATUS_SENDING = "sending" STATUS_DELIVERED = "delivered" STATUS_FAILED = "failed" DELIVERY_LOG = 20 _DELIVERY_COLS = ( "id, webhook_id, alert_id, event, status, attempts, next_attempt_at, " "response_code, last_error, created_at, finished_at" ) _HOOKS = f""" SELECT w.id, w.account_id, w.url, w.secret, w.enabled, w.watch_ids, w.created_at, w.updated_at, to_jsonb(d) AS last_delivery FROM webhooks w LEFT JOIN LATERAL ( SELECT {_DELIVERY_COLS} FROM webhook_deliveries WHERE webhook_id = w.id ORDER BY id DESC LIMIT 1 ) d ON true """ # noqa: S608 _CLAIM = """ UPDATE webhook_deliveries d SET status = 'sending', attempts = d.attempts + 1 FROM webhooks w WHERE w.id = d.webhook_id AND d.id IN ( SELECT id FROM webhook_deliveries WHERE status = 'pending' AND next_attempt_at <= now() ORDER BY next_attempt_at LIMIT $1 FOR UPDATE SKIP LOCKED ) RETURNING d.id, d.alert_id, d.event, d.attempts, d.body, w.id AS webhook_id, w.account_id, w.url, w.secret """ _FINISH = """ UPDATE webhook_deliveries SET status = $2, response_code = $3, last_error = $4, next_attempt_at = coalesce($5, next_attempt_at), finished_at = CASE WHEN $2 = 'pending' THEN NULL ELSE now() END WHERE id = $1 """ def new_secret() -> str: return secrets.token_hex(32) def _to_webhook(row: asyncpg.Record) -> WebhookView: data = dict(row) last = data.pop("last_delivery") return WebhookView( **data, last_delivery=DeliveryView(**json.loads(last)) if last else None ) async def list_webhooks(pool: asyncpg.Pool, account_id: int) -> list[WebhookView]: rows = await pool.fetch( f"{_HOOKS} WHERE w.account_id = $1 ORDER BY w.id", account_id ) return [_to_webhook(row) for row in rows] async def get_webhook(pool: asyncpg.Pool, webhook_id: int) -> WebhookView | None: row = await pool.fetchrow(f"{_HOOKS} WHERE w.id = $1", webhook_id) return _to_webhook(row) if row else None async def create_webhook( pool: asyncpg.Pool, account_id: int, url: str, watch_ids: list[int] | None, *, enabled: bool, ) -> WebhookView | None: webhook_id = await pool.fetchval( "INSERT INTO webhooks (account_id, url, secret, enabled, watch_ids) " "VALUES ($1, $2, $3, $4, $5) RETURNING id", account_id, url, new_secret(), enabled, watch_ids, ) return await get_webhook(pool, webhook_id) async def update_webhook( pool: asyncpg.Pool, webhook_id: int, url: str, watch_ids: list[int] | None, *, enabled: bool, ) -> WebhookView | None: async with pool.acquire() as conn, conn.transaction(): found = await conn.fetchval( "UPDATE webhooks SET url = $2, enabled = $3, watch_ids = $4, " "updated_at = now() WHERE id = $1 RETURNING id", webhook_id, url, enabled, watch_ids, ) if found is not None and not enabled: await conn.execute( "UPDATE webhook_deliveries SET status = 'failed', " "last_error = 'endpoint disabled', finished_at = now() " "WHERE webhook_id = $1 AND status = 'pending'", webhook_id, ) return await get_webhook(pool, webhook_id) async def rotate_secret(pool: asyncpg.Pool, webhook_id: int) -> WebhookView | None: await pool.execute( "UPDATE webhooks SET secret = $2, updated_at = now() WHERE id = $1", webhook_id, new_secret(), ) return await get_webhook(pool, webhook_id) async def delete_webhook(pool: asyncpg.Pool, webhook_id: int) -> bool: result = await pool.execute("DELETE FROM webhooks WHERE id = $1", webhook_id) return result.endswith("1") async def list_deliveries( pool: asyncpg.Pool, webhook_id: int, limit: int = DELIVERY_LOG ) -> list[DeliveryView]: rows = await pool.fetch( f"SELECT {_DELIVERY_COLS} FROM webhook_deliveries " # noqa: S608 "WHERE webhook_id = $1 ORDER BY id DESC LIMIT $2", webhook_id, limit, ) return [DeliveryView(**dict(row)) for row in rows] async def get_delivery(pool: asyncpg.Pool, delivery_id: int) -> DeliveryView | None: row = await pool.fetchrow( f"SELECT {_DELIVERY_COLS} FROM webhook_deliveries WHERE id = $1", # noqa: S608 delivery_id, ) return DeliveryView(**dict(row)) if row else None async def start_test(pool: asyncpg.Pool, webhook_id: int, body: str) -> int: return await pool.fetchval( "INSERT INTO webhook_deliveries (webhook_id, event, status, attempts, body) " "VALUES ($1, 'test', 'sending', 1, $2) RETURNING id", webhook_id, body, ) async def release_stuck(pool: asyncpg.Pool) -> None: await pool.execute( "UPDATE webhook_deliveries SET status = 'pending' WHERE status = 'sending'" ) async def claim_due(pool: asyncpg.Pool, limit: int) -> list[asyncpg.Record]: return await pool.fetch(_CLAIM, limit) async def save_body(pool: asyncpg.Pool, delivery_id: int, body: str) -> None: await pool.execute( "UPDATE webhook_deliveries SET body = $2 WHERE id = $1", delivery_id, body ) async def finish( # noqa: PLR0913 pool: asyncpg.Pool, delivery_id: int, status: str, response_code: int | None, error: str | None, next_attempt_at: datetime | None = None, ) -> None: await pool.execute( _FINISH, delivery_id, status, response_code, error, next_attempt_at ) async def next_due(pool: asyncpg.Pool) -> datetime | None: return await pool.fetchval( "SELECT min(next_attempt_at) FROM webhook_deliveries WHERE status = 'pending'" )