- 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>
121 lines
4.4 KiB
Python
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}
|