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>
This commit is contained in:
120
gogo/api/webhooks.py
Normal file
120
gogo/api/webhooks.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user