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:
2026-07-11 09:45:06 +02:00
commit e855650f09
48 changed files with 4941 additions and 0 deletions

0
gogo/api/__init__.py Normal file
View File

190
gogo/api/actions.py Normal file
View 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
View 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
View 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}