Files
gogo-telefon/gogo/api/webhooks.py
Senad Uka e855650f09 M1: backend core + proposal engine
- FastAPI skeleton, SQLAlchemy models (§13), Alembic initial migration
- SchedulingProvider interface with google_calendar (free/busy read-only),
  partner_api (Appendix B client) and mock implementations
- Proposal engine: create → provider-routed delivery → owner actions
  (resolve/confirm+SMS/reject) → expiry + reminders (§9)
- Signed single-use action links, .ics METHOD:REQUEST attachment
- Partner outcome webhook with HMAC verification + polling fallback
- SmsProvider (console) with Bosnian templates (§5.5), EmailProvider (console/SMTP)
- Fake partner API server in tests/ — Appendix B reference implementation
- 43 tests: slot math, proposal lifecycle, action links, partner contract

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:45:06 +02:00

121 lines
4.4 KiB
Python

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