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:
0
gogo/api/__init__.py
Normal file
0
gogo/api/__init__.py
Normal file
190
gogo/api/actions.py
Normal file
190
gogo/api/actions.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Owner action links from proposal emails (§9.1-9.2): /a/{token}.
|
||||
|
||||
Signed, single-use, no login required, idempotent (§15). All owner-facing
|
||||
copy is Bosnian. Reject shows a minimal form to edit the client SMS before
|
||||
sending; confirm re-checks free/busy and warns if the slot has been taken.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Form
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gogo.db import get_session
|
||||
from gogo.domain import BookingStatus, Slot
|
||||
from gogo.i18n import fmt_slot
|
||||
from gogo.models import ActionToken, BookingRequest, Tenant, utcnow
|
||||
from gogo.proposals import engine
|
||||
from gogo.proposals.tokens import unsign
|
||||
from gogo.sms.templates import render_sms
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_PAGE = """<!doctype html><html lang="bs"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Gogo Telefon</title>
|
||||
<style>
|
||||
body{{font-family:system-ui,sans-serif;background:#f4f4f5;margin:0;padding:24px;
|
||||
display:flex;justify-content:center}}
|
||||
.card{{background:#fff;border-radius:12px;padding:32px;max-width:480px;width:100%;
|
||||
box-shadow:0 1px 4px rgba(0,0,0,.08)}}
|
||||
h1{{font-size:20px;margin-top:0}} p{{line-height:1.5}}
|
||||
.ok{{color:#16a34a}} .warn{{color:#d97706}} .err{{color:#dc2626}}
|
||||
textarea{{width:100%;min-height:90px;font:inherit;padding:8px;box-sizing:border-box}}
|
||||
button,a.btn{{background:#2563eb;color:#fff;border:0;border-radius:8px;padding:12px 20px;
|
||||
font-size:15px;cursor:pointer;text-decoration:none;display:inline-block;margin-top:8px}}
|
||||
button.green{{background:#16a34a}} button.red{{background:#dc2626}}
|
||||
.muted{{color:#71717a;font-size:13px;margin-top:24px}}
|
||||
</style></head><body><div class="card">{body}
|
||||
<p class="muted">Gogo Telefon — virtuelni asistent</p></div></body></html>"""
|
||||
|
||||
|
||||
def page(body: str, status_code: int = 200) -> HTMLResponse:
|
||||
return HTMLResponse(_PAGE.format(body=body), status_code=status_code)
|
||||
|
||||
|
||||
STATUS_LABEL = {
|
||||
BookingStatus.pending.value: "na čekanju",
|
||||
BookingStatus.resolved_by_owner.value: "riješen — klijent kontaktiran",
|
||||
BookingStatus.confirmed.value: "potvrđen",
|
||||
BookingStatus.rejected.value: "odbijen",
|
||||
BookingStatus.expired.value: "istekao",
|
||||
}
|
||||
|
||||
|
||||
async def _load(token: str, session: AsyncSession):
|
||||
raw = unsign(token)
|
||||
if raw is None:
|
||||
return None, None, None, page(
|
||||
"<h1 class='err'>Nevažeći link</h1><p>Link je oštećen ili nije ispravan.</p>", 400
|
||||
)
|
||||
at = (
|
||||
await session.execute(select(ActionToken).where(ActionToken.token == raw))
|
||||
).scalar_one_or_none()
|
||||
if at is None:
|
||||
return None, None, None, page(
|
||||
"<h1 class='err'>Nepoznat link</h1><p>Ovaj link više ne postoji.</p>", 404
|
||||
)
|
||||
req = (
|
||||
await session.execute(select(BookingRequest).where(BookingRequest.id == at.request_id))
|
||||
).scalar_one()
|
||||
tenant = (
|
||||
await session.execute(select(Tenant).where(Tenant.id == req.tenant_id))
|
||||
).scalar_one()
|
||||
return at, req, tenant, None
|
||||
|
||||
|
||||
def _already_done(req: BookingRequest) -> HTMLResponse:
|
||||
label = STATUS_LABEL.get(req.status, req.status)
|
||||
return page(
|
||||
f"<h1 class='ok'>Zahtjev je već obrađen</h1>"
|
||||
f"<p>Status zahtjeva klijenta <b>{req.client_name}</b>: <b>{label}</b>.</p>"
|
||||
"<p>Nije potrebna dodatna akcija.</p>"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/a/{token}", response_class=HTMLResponse)
|
||||
async def action_get(token: str, session: AsyncSession = Depends(get_session)):
|
||||
at, req, tenant, err = await _load(token, session)
|
||||
if err:
|
||||
return err
|
||||
|
||||
if req.status != BookingStatus.pending.value:
|
||||
return _already_done(req)
|
||||
|
||||
if at.action == "resolve":
|
||||
await engine.resolve_request(session, tenant, req)
|
||||
at.used_at = utcnow()
|
||||
await session.commit()
|
||||
return page(
|
||||
"<h1 class='ok'>✓ Označeno kao riješeno</h1>"
|
||||
f"<p>Zahtjev klijenta <b>{req.client_name}</b> je zatvoren. "
|
||||
"Klijentu <b>nije</b> poslan SMS — dogovorili ste se direktno.</p>"
|
||||
"<p>Ne zaboravite upisati termin u svoj kalendar (možete iskoristiti "
|
||||
"priloženi .ics iz emaila).</p>"
|
||||
)
|
||||
|
||||
if at.action == "confirm":
|
||||
slots = [Slot.model_validate(s) for s in (req.slots or [])]
|
||||
idx = at.slot_index or 0
|
||||
if idx >= len(slots):
|
||||
return page("<h1 class='err'>Greška</h1><p>Traženi termin ne postoji.</p>", 400)
|
||||
slot = slots[idx]
|
||||
ok = await engine.confirm_request(session, tenant, req, slot, recheck=True)
|
||||
if not ok:
|
||||
# slot taken since — warn, offer force (§9.2)
|
||||
return page(
|
||||
"<h1 class='warn'>⚠ Termin je u međuvremenu zauzet</h1>"
|
||||
f"<p>U kalendaru više nije slobodno: <b>{fmt_slot(slot.start, tenant.timezone)}</b>.</p>"
|
||||
"<p>Možete svejedno potvrditi (npr. ako ste sami upisali ovaj termin "
|
||||
"u kalendar), ili se javiti klijentu direktno.</p>"
|
||||
f"<form method='post' action='/a/{token}/force-confirm'>"
|
||||
"<button class='green'>Svejedno potvrdi i pošalji SMS</button></form>"
|
||||
)
|
||||
at.used_at = utcnow()
|
||||
await session.commit()
|
||||
return page(
|
||||
"<h1 class='ok'>✓ Termin potvrđen</h1>"
|
||||
f"<p>Klijentu <b>{req.client_name}</b> je poslan SMS s potvrdom za "
|
||||
f"<b>{fmt_slot(slot.start, tenant.timezone)}</b>.</p>"
|
||||
"<p>Ne zaboravite upisati termin u svoj kalendar.</p>"
|
||||
)
|
||||
|
||||
if at.action == "reject":
|
||||
default_sms = render_sms(tenant, "rejection")
|
||||
return page(
|
||||
"<h1>Odbij zahtjev</h1>"
|
||||
f"<p>Klijent <b>{req.client_name}</b> ({req.client_phone}) će dobiti ovu poruku — "
|
||||
"možete je izmijeniti prije slanja:</p>"
|
||||
f"<form method='post' action='/a/{token}/reject'>"
|
||||
f"<textarea name='sms_body'>{default_sms}</textarea>"
|
||||
"<button class='red'>Pošalji i odbij zahtjev</button></form>"
|
||||
)
|
||||
|
||||
return page("<h1 class='err'>Nepoznata akcija</h1>", 400)
|
||||
|
||||
|
||||
@router.post("/a/{token}/reject", response_class=HTMLResponse)
|
||||
async def action_reject(
|
||||
token: str, sms_body: str = Form(""), session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
at, req, tenant, err = await _load(token, session)
|
||||
if err:
|
||||
return err
|
||||
if at.action != "reject":
|
||||
return page("<h1 class='err'>Nepoznata akcija</h1>", 400)
|
||||
if req.status != BookingStatus.pending.value:
|
||||
return _already_done(req)
|
||||
await engine.reject_request(session, tenant, req, custom_sms=sms_body.strip() or None)
|
||||
at.used_at = utcnow()
|
||||
await session.commit()
|
||||
return page(
|
||||
"<h1 class='ok'>Zahtjev odbijen</h1>"
|
||||
f"<p>Klijentu <b>{req.client_name}</b> je poslan SMS s obavještenjem.</p>"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/a/{token}/force-confirm", response_class=HTMLResponse)
|
||||
async def action_force_confirm(token: str, session: AsyncSession = Depends(get_session)):
|
||||
at, req, tenant, err = await _load(token, session)
|
||||
if err:
|
||||
return err
|
||||
if at.action != "confirm":
|
||||
return page("<h1 class='err'>Nepoznata akcija</h1>", 400)
|
||||
if req.status != BookingStatus.pending.value:
|
||||
return _already_done(req)
|
||||
slots = [Slot.model_validate(s) for s in (req.slots or [])]
|
||||
idx = at.slot_index or 0
|
||||
if idx >= len(slots):
|
||||
return page("<h1 class='err'>Greška</h1><p>Traženi termin ne postoji.</p>", 400)
|
||||
slot = slots[idx]
|
||||
await engine.confirm_request(session, tenant, req, slot, recheck=False)
|
||||
at.used_at = utcnow()
|
||||
await session.commit()
|
||||
return page(
|
||||
"<h1 class='ok'>✓ Termin potvrđen</h1>"
|
||||
f"<p>Klijentu <b>{req.client_name}</b> je poslan SMS s potvrdom za "
|
||||
f"<b>{fmt_slot(slot.start, tenant.timezone)}</b>.</p>"
|
||||
)
|
||||
23
gogo/api/health.py
Normal file
23
gogo/api/health.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Health/observability endpoints (§15)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
import gogo
|
||||
from gogo.db import get_session
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "version": gogo.__version__}
|
||||
|
||||
|
||||
@router.get("/health/db")
|
||||
async def health_db(session: AsyncSession = Depends(get_session)):
|
||||
await session.execute(text("SELECT 1"))
|
||||
return {"status": "ok"}
|
||||
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