Files
gogo-telefon/gogo/api/webhooks.py

121 lines
4.4 KiB
Python
Raw Normal View History

"""Partner outcome webhook (§B.3): POST /webhooks/partner/{tenant_id}.
HMAC-SHA256 of the raw body with the per-tenant shared secret, sent as
X-Gogo-Signature: sha256=<hex>. Idempotent per (gogo_request_id, outcome).
Drives the same transitions (and client SMS) as the email actions.
"""
from __future__ import annotations
import hashlib
import hmac
import logging
import uuid
from fastapi import APIRouter, Depends, Header, Request
from fastapi.responses import JSONResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from gogo.crypto import decrypt
from gogo.db import get_session
from gogo.domain import BookingStatus, Slot
from gogo.models import BookingRequest, ProviderConfig, Tenant
from gogo.proposals import engine
log = logging.getLogger("gogo.webhooks")
router = APIRouter()
def _err(status: int, code: str, message: str) -> JSONResponse:
return JSONResponse({"error": {"code": code, "message": message}}, status_code=status)
def verify_signature(secret: str, body: bytes, header_value: str | None) -> bool:
if not header_value or not header_value.startswith("sha256="):
return False
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(header_value.removeprefix("sha256="), expected)
@router.post("/webhooks/partner/{tenant_id}")
async def partner_webhook(
tenant_id: str,
request: Request,
session: AsyncSession = Depends(get_session),
x_gogo_signature: str | None = Header(default=None),
):
try:
tid = uuid.UUID(tenant_id)
except ValueError:
return _err(404, "unknown_tenant", "Unknown tenant id")
tenant = (
await session.execute(select(Tenant).where(Tenant.id == tid))
).scalar_one_or_none()
if tenant is None:
return _err(404, "unknown_tenant", "Unknown tenant id")
config_row = (
await session.execute(select(ProviderConfig).where(ProviderConfig.tenant_id == tid))
).scalar_one_or_none()
secret_enc = (config_row.config if config_row else {}).get("webhook_secret_encrypted")
if not secret_enc:
return _err(409, "not_configured", "Webhook secret not configured for tenant")
body = await request.body()
if not verify_signature(decrypt(secret_enc), body, x_gogo_signature):
return _err(401, "bad_signature", "Invalid or missing X-Gogo-Signature")
try:
payload = await request.json()
gogo_request_id = uuid.UUID(payload["gogo_request_id"])
outcome = payload["outcome"]
except Exception: # noqa: BLE001
return _err(400, "bad_payload", "Malformed JSON payload")
if outcome not in ("confirmed", "resolved", "rejected"):
return _err(400, "bad_outcome", f"Unknown outcome: {outcome}")
req = (
await session.execute(
select(BookingRequest).where(
BookingRequest.id == gogo_request_id, BookingRequest.tenant_id == tid
)
)
).scalar_one_or_none()
if req is None:
return _err(404, "unknown_request", "Unknown gogo_request_id")
if payload.get("partner_request_id") and not req.partner_request_id:
req.partner_request_id = str(payload["partner_request_id"])
# Idempotency per (gogo_request_id, outcome) (§B.3)
target = {
"confirmed": BookingStatus.confirmed.value,
"resolved": BookingStatus.resolved_by_owner.value,
"rejected": BookingStatus.rejected.value,
}[outcome]
if req.status == target:
return {"ok": True}
if req.status != BookingStatus.pending.value:
return _err(409, "conflict", f"Request already {req.status}")
if outcome == "confirmed":
slot_data = payload.get("confirmed_slot")
if not slot_data:
return _err(400, "missing_slot", "confirmed_slot is required for outcome=confirmed")
try:
slot = Slot.model_validate(slot_data)
except Exception: # noqa: BLE001
return _err(400, "bad_slot", "Malformed confirmed_slot")
# Partner software is the source of truth — no free/busy recheck (§8.2)
await engine.confirm_request(session, tenant, req, slot, recheck=False)
elif outcome == "resolved":
await engine.resolve_request(session, tenant, req, note=payload.get("note", ""))
else:
await engine.reject_request(session, tenant, req)
await session.commit()
log.info("webhook: request %s%s (tenant %s)", req.id, outcome, tenant.slug)
return {"ok": True}