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

View File

@@ -0,0 +1 @@
from gogo.scheduling.base import SchedulingProvider, get_provider # noqa: F401

72
gogo/scheduling/base.py Normal file
View File

@@ -0,0 +1,72 @@
"""SchedulingProvider interface (§8) and per-tenant provider resolution.
Adding a third provider = one new module registering itself here. No changes
to the agent, tools, or proposal engine are needed.
"""
from __future__ import annotations
from datetime import date
from typing import Protocol, runtime_checkable
from sqlalchemy.ext.asyncio import AsyncSession
from gogo.domain import BookingRequestData, DeliveryResult, ServiceInfo, Slot
from gogo.models import ProviderConfig, Tenant
@runtime_checkable
class SchedulingProvider(Protocol):
"""Availability lookup + proposal delivery for one tenant."""
async def get_services(self) -> list[ServiceInfo] | None:
"""Optional catalog sync; None = provider has no catalog."""
...
async def get_availability(
self, service_id: str, date_from: date, date_to: date, home_visit: bool = False
) -> list[Slot]:
"""Ready-to-offer free slots. The agent picks up to 3."""
...
async def deliver_request(self, booking_request: BookingRequestData) -> DeliveryResult:
"""Deliver the proposal to the owner (email / partner push).
Outcome is signaled back via webhook/polling (partner) or the owner's
email/dashboard actions (google_calendar, mock).
"""
...
_REGISTRY: dict[str, type] = {}
def register_provider(name: str):
def deco(cls):
_REGISTRY[name] = cls
return cls
return deco
async def get_provider(session: AsyncSession, tenant: Tenant) -> SchedulingProvider:
"""Instantiate the tenant's configured provider."""
# Imports here to avoid circulars; modules self-register on import.
from gogo.scheduling import google_calendar, mock, partner_api # noqa: F401
cls = _REGISTRY.get(tenant.scheduling_provider)
if cls is None:
raise ValueError(f"Unknown scheduling provider: {tenant.scheduling_provider}")
config = await _load_config(session, tenant)
return cls(session=session, tenant=tenant, config=config)
async def _load_config(session: AsyncSession, tenant: Tenant) -> dict:
from sqlalchemy import select
row = (
await session.execute(
select(ProviderConfig).where(ProviderConfig.tenant_id == tenant.id)
)
).scalar_one_or_none()
return row.config if row else {}

View File

@@ -0,0 +1,211 @@
"""Google Calendar provider (§8.1) — free/busy READ ONLY.
Gogo never requests calendar write access and never creates/modifies events.
Availability = tenant working hours minus busy blocks from the mapped calendar,
quantized to service duration. Delivery = owner email with action links + .ics.
Uses raw HTTP (httpx) against the Calendar v3 freeBusy endpoint + OAuth token
refresh — no heavy Google SDK, easy to point at a fake server in tests.
"""
from __future__ import annotations
import json
import logging
import uuid
from datetime import UTC, date, datetime, timedelta
from zoneinfo import ZoneInfo
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from gogo.crypto import decrypt, encrypt
from gogo.domain import BookingRequestData, DeliveryResult, ServiceInfo, Slot
from gogo.hours import DEFAULT_WORKING_HOURS
from gogo.models import (
BookingRequest,
CalendarConnection,
CalendarMapping,
Service,
Tenant,
utcnow,
)
from gogo.scheduling.base import register_provider
from gogo.scheduling.slots import compute_slots
log = logging.getLogger("gogo.gcal")
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_FREEBUSY_URL = "https://www.googleapis.com/calendar/v3/freeBusy"
@register_provider("google_calendar")
class GoogleCalendarProvider:
# test hooks: override endpoints / freeze time
token_url: str = GOOGLE_TOKEN_URL
freebusy_url: str = GOOGLE_FREEBUSY_URL
now_override: datetime | None = None
def __init__(self, session: AsyncSession, tenant: Tenant, config: dict):
self.session = session
self.tenant = tenant
self.config = config
async def get_services(self) -> list[ServiceInfo] | None:
return None # services are maintained in the Gogo dashboard
async def get_availability(
self, service_id: str, date_from: date, date_to: date, home_visit: bool = False
) -> list[Slot]:
service = (
await self.session.execute(
select(Service).where(Service.id == uuid.UUID(service_id))
)
).scalar_one_or_none()
duration = service.duration_min if service else 30
buffer_min = service.buffer_min if service else 0
tz = ZoneInfo(self.tenant.timezone)
calendar_id = await self._calendar_for_service(service)
busy = await self.fetch_busy(calendar_id, date_from, date_to, tz)
return compute_slots(
working_hours=self.tenant.working_hours or DEFAULT_WORKING_HOURS,
busy=busy,
duration_min=duration,
buffer_min=buffer_min,
date_from=date_from,
date_to=date_to,
tz=tz,
now=self.now_override or utcnow(),
min_notice_hours=self.tenant.min_notice_hours,
max_days_ahead=self.tenant.max_days_ahead,
)
async def is_slot_free(self, service_id: str | None, slot: Slot) -> bool:
"""Re-check free/busy before 'confirm + SMS' (§9.2)."""
service = None
if service_id:
service = (
await self.session.execute(
select(Service).where(Service.id == uuid.UUID(str(service_id)))
)
).scalar_one_or_none()
calendar_id = await self._calendar_for_service(service)
tz = ZoneInfo(self.tenant.timezone)
busy = await self.fetch_busy(
calendar_id, slot.start.date(), slot.end.date(), tz
)
return not any(b_start < slot.end and b_end > slot.start for b_start, b_end in busy)
async def deliver_request(self, booking_request: BookingRequestData) -> DeliveryResult:
if booking_request.dry_run:
return DeliveryResult(ok=True, detail="dry-run ok (google_calendar)")
from gogo.proposals.email import send_proposal_email
req = (
await self.session.execute(
select(BookingRequest).where(
BookingRequest.id == uuid.UUID(booking_request.gogo_request_id)
)
)
).scalar_one()
service = None
if req.service_id:
service = (
await self.session.execute(select(Service).where(Service.id == req.service_id))
).scalar_one_or_none()
await send_proposal_email(self.session, self.tenant, req, service)
return DeliveryResult(ok=True, detail="email sent")
# -- internals ---------------------------------------------------------
async def _calendar_for_service(self, service: Service | None) -> str:
"""Mapped calendar for the service, tenant default mapping, or 'primary'."""
if service is not None:
row = (
await self.session.execute(
select(CalendarMapping).where(
CalendarMapping.tenant_id == self.tenant.id,
CalendarMapping.service_id == service.id,
)
)
).scalar_one_or_none()
if row:
return row.google_calendar_id
default = (
await self.session.execute(
select(CalendarMapping).where(
CalendarMapping.tenant_id == self.tenant.id,
CalendarMapping.service_id.is_(None),
)
)
).scalar_one_or_none()
return default.google_calendar_id if default else "primary"
async def fetch_busy(
self, calendar_id: str, date_from: date, date_to: date, tz: ZoneInfo
) -> list[tuple[datetime, datetime]]:
conn = (
await self.session.execute(
select(CalendarConnection).where(CalendarConnection.tenant_id == self.tenant.id)
)
).scalar_one_or_none()
if conn is None:
log.warning("tenant %s: google_calendar provider without connection", self.tenant.id)
return []
access_token = await self._fresh_access_token(conn)
time_min = datetime.combine(date_from, datetime.min.time(), tzinfo=tz)
time_max = datetime.combine(date_to + timedelta(days=1), datetime.min.time(), tzinfo=tz)
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
self.freebusy_url,
headers={"Authorization": f"Bearer {access_token}"},
json={
"timeMin": time_min.isoformat(),
"timeMax": time_max.isoformat(),
"items": [{"id": calendar_id}],
},
)
resp.raise_for_status()
data = resp.json()
busy = []
for cal in data.get("calendars", {}).values():
for block in cal.get("busy", []):
busy.append(
(
datetime.fromisoformat(block["start"]),
datetime.fromisoformat(block["end"]),
)
)
return busy
async def _fresh_access_token(self, conn: CalendarConnection) -> str:
token_data = json.loads(decrypt(conn.token_data_encrypted))
expiry = token_data.get("expiry")
if expiry and datetime.fromisoformat(expiry) > datetime.now(UTC) + timedelta(minutes=2):
return token_data["access_token"]
# refresh
from gogo.config import get_settings
s = get_settings()
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
self.token_url,
data={
"client_id": s.google_client_id,
"client_secret": s.google_client_secret,
"refresh_token": token_data["refresh_token"],
"grant_type": "refresh_token",
},
)
resp.raise_for_status()
fresh = resp.json()
token_data["access_token"] = fresh["access_token"]
token_data["expiry"] = (
datetime.now(UTC) + timedelta(seconds=fresh.get("expires_in", 3600))
).isoformat()
conn.token_data_encrypted = encrypt(json.dumps(token_data))
return token_data["access_token"]

81
gogo/scheduling/mock.py Normal file
View File

@@ -0,0 +1,81 @@
"""Mock scheduling provider — in-memory, for tests and demos (§16 M1).
Availability: generated from tenant working hours with configurable busy blocks
(class-level, settable by tests/demo seeder). Delivery: owner email, same as
google_calendar.
"""
from __future__ import annotations
import uuid
from datetime import date, datetime
from zoneinfo import ZoneInfo
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from gogo.domain import BookingRequestData, DeliveryResult, ServiceInfo, Slot
from gogo.hours import DEFAULT_WORKING_HOURS
from gogo.models import BookingRequest, Service, Tenant, utcnow
from gogo.scheduling.base import register_provider
from gogo.scheduling.slots import compute_slots
@register_provider("mock")
class MockProvider:
# tenant_id(str) -> list[(start, end)] busy blocks; tests populate this
busy_blocks: dict[str, list[tuple[datetime, datetime]]] = {}
# test hook: freeze "now"
now_override: datetime | None = None
def __init__(self, session: AsyncSession, tenant: Tenant, config: dict):
self.session = session
self.tenant = tenant
self.config = config
async def get_services(self) -> list[ServiceInfo] | None:
return None # no external catalog
async def get_availability(
self, service_id: str, date_from: date, date_to: date, home_visit: bool = False
) -> list[Slot]:
service = (
await self.session.execute(
select(Service).where(Service.id == uuid.UUID(service_id))
)
).scalar_one_or_none()
duration = service.duration_min if service else 30
buffer_min = service.buffer_min if service else 0
tz = ZoneInfo(self.tenant.timezone)
return compute_slots(
working_hours=self.tenant.working_hours or DEFAULT_WORKING_HOURS,
busy=self.busy_blocks.get(str(self.tenant.id), []),
duration_min=duration,
buffer_min=buffer_min,
date_from=date_from,
date_to=date_to,
tz=tz,
now=self.now_override or utcnow(),
min_notice_hours=self.tenant.min_notice_hours,
max_days_ahead=self.tenant.max_days_ahead,
)
async def deliver_request(self, booking_request: BookingRequestData) -> DeliveryResult:
if booking_request.dry_run:
return DeliveryResult(ok=True, detail="dry-run ok (mock)")
from gogo.proposals.email import send_proposal_email
req = (
await self.session.execute(
select(BookingRequest).where(
BookingRequest.id == uuid.UUID(booking_request.gogo_request_id)
)
)
).scalar_one()
service = None
if req.service_id:
service = (
await self.session.execute(select(Service).where(Service.id == req.service_id))
).scalar_one_or_none()
await send_proposal_email(self.session, self.tenant, req, service)
return DeliveryResult(ok=True, detail="email sent (mock)")

View File

@@ -0,0 +1,226 @@
"""Partner API provider (§8.2, Appendix B).
The salon's own booking software is the source of truth: availability is pulled
from `GET /availability` (ready slots, no slot math on our side) and booking
requests are pushed via `POST /booking-requests` with an Idempotency-Key.
Outcomes arrive on our webhook (gogo/api/webhooks.py) or via polling fallback.
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from datetime import date, datetime
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from gogo.crypto import decrypt
from gogo.domain import BookingRequestData, DeliveryResult, ServiceInfo, Slot
from gogo.models import BookingRequest, Service, Tenant
from gogo.scheduling.base import register_provider
log = logging.getLogger("gogo.partner")
RETRIES = 3 # 5xx retries with exponential backoff (§B.0)
RETRY_BASE_DELAY = 0.5
@register_provider("partner_api")
class PartnerApiProvider:
# test hook: factory for the HTTP client (e.g. httpx.ASGITransport against a fake app)
client_factory = staticmethod(lambda timeout: httpx.AsyncClient(timeout=timeout))
def __init__(self, session: AsyncSession, tenant: Tenant, config: dict):
self.session = session
self.tenant = tenant
self.config = config
@property
def base_url(self) -> str:
return (self.config.get("base_url") or "").rstrip("/")
@property
def api_key(self) -> str:
enc = self.config.get("api_key_encrypted")
return decrypt(enc) if enc else ""
def _headers(self) -> dict[str, str]:
return {"Authorization": f"Bearer {self.api_key}"}
async def get_services(self) -> list[ServiceInfo] | None:
"""Catalog sync from GET /services (optional endpoint, §B.4)."""
if not self.config.get("catalog_sync"):
return None
data = await self._get("/services")
return [
ServiceInfo(
id=str(s["id"]),
name=s["name"],
duration_min=int(s.get("duration_min", 30)),
price_min=s.get("price_min"),
price_max=s.get("price_max"),
currency=s.get("currency", "BAM"),
home_visit=bool(s.get("home_visit", False)),
active=bool(s.get("active", True)),
)
for s in data.get("services", [])
]
async def get_availability(
self, service_id: str, date_from: date, date_to: date, home_visit: bool = False
) -> list[Slot]:
partner_service_id = await self._partner_service_id(service_id)
data = await self._get(
"/availability",
params={
"service_id": partner_service_id,
"from": date_from.isoformat(),
"to": date_to.isoformat(),
"home_visit": "true" if home_visit else "false",
},
# §B.1: the voice agent is waiting mid-conversation
timeout=2.0,
)
return [
Slot(
start=datetime.fromisoformat(s["start"]),
end=datetime.fromisoformat(s["end"]),
staff_id=s.get("staff_id"),
staff_name=s.get("staff_name"),
)
for s in data.get("slots", [])
]
async def deliver_request(self, booking_request: BookingRequestData) -> DeliveryResult:
payload = {
"gogo_request_id": booking_request.gogo_request_id,
"created_at": booking_request.created_at.isoformat(),
"source": booking_request.source,
"client": {
"name": booking_request.client_name,
"phone": booking_request.client_phone,
},
"service_id": (
await self._partner_service_id(booking_request.service_id)
if booking_request.service_id
else None
),
"service_name_raw": booking_request.service_name_raw,
"requested_slots": [
{"start": s.start.isoformat(), "end": s.end.isoformat()}
for s in booking_request.requested_slots
],
"time_preference_text": booking_request.time_preference_text,
"home_visit": booking_request.home_visit,
"address": booking_request.address,
"summary": booking_request.summary,
"transcript_url": booking_request.transcript_url,
"dry_run": booking_request.dry_run,
}
try:
data = await self._post(
"/booking-requests",
json=payload,
headers={"Idempotency-Key": booking_request.gogo_request_id},
)
except Exception as e: # noqa: BLE001
log.exception("partner push failed tenant=%s", self.tenant.id)
return DeliveryResult(ok=False, detail=f"partner push failed: {e}")
partner_id = data.get("partner_request_id")
if not booking_request.dry_run and partner_id:
req = (
await self.session.execute(
select(BookingRequest).where(
BookingRequest.id == uuid.UUID(booking_request.gogo_request_id)
)
)
).scalar_one_or_none()
if req:
req.partner_request_id = str(partner_id)
# Email to the owner is optional for partner tenants (default off, §8.2)
if not booking_request.dry_run and self.config.get("email_to_owner"):
from gogo.proposals.email import send_proposal_email
req = (
await self.session.execute(
select(BookingRequest).where(
BookingRequest.id == uuid.UUID(booking_request.gogo_request_id)
)
)
).scalar_one_or_none()
if req:
service = None
if req.service_id:
service = (
await self.session.execute(
select(Service).where(Service.id == req.service_id)
)
).scalar_one_or_none()
await send_proposal_email(self.session, self.tenant, req, service)
return DeliveryResult(ok=True, partner_request_id=partner_id, detail="pushed to partner")
async def poll_status(self, partner_request_id: str) -> dict:
"""Polling fallback (§B.3): GET /booking-requests/{id}."""
return await self._get(f"/booking-requests/{partner_request_id}")
# -- internals ---------------------------------------------------------
async def _partner_service_id(self, service_id: str | uuid.UUID | None) -> str | None:
"""Our service UUID → partner's service id (services.partner_service_id)."""
if service_id is None:
return None
service = (
await self.session.execute(
select(Service).where(Service.id == uuid.UUID(str(service_id)))
)
).scalar_one_or_none()
if service and service.partner_service_id:
return service.partner_service_id
return str(service_id)
async def _get(self, path: str, params: dict | None = None, timeout: float = 10.0) -> dict:
return await self._request("GET", path, params=params, timeout=timeout)
async def _post(
self, path: str, json: dict, headers: dict | None = None, timeout: float = 10.0
) -> dict:
return await self._request("POST", path, json=json, headers=headers, timeout=timeout)
async def _request(
self,
method: str,
path: str,
*,
params: dict | None = None,
json: dict | None = None,
headers: dict | None = None,
timeout: float = 10.0,
) -> dict:
url = f"{self.base_url}{path}"
hdrs = {**self._headers(), **(headers or {})}
last_exc: Exception | None = None
async with self.client_factory(timeout=timeout) as client:
for attempt in range(RETRIES):
try:
resp = await client.request(
method, url, params=params, json=json, headers=hdrs
)
except httpx.HTTPError as e:
last_exc = e
await asyncio.sleep(RETRY_BASE_DELAY * 2**attempt)
continue
if resp.status_code >= 500:
last_exc = httpx.HTTPStatusError(
f"{resp.status_code} from partner", request=resp.request, response=resp
)
await asyncio.sleep(RETRY_BASE_DELAY * 2**attempt)
continue
resp.raise_for_status() # 4xx: not retried, surfaced (§B.0)
return resp.json()
raise last_exc if last_exc else RuntimeError("partner request failed")

77
gogo/scheduling/slots.py Normal file
View File

@@ -0,0 +1,77 @@
"""Slot generation for Google Calendar tenants (§8.1).
working hours busy blocks, quantized to service duration (+ optional buffer),
respecting min_notice_hours and max_days_ahead. Partner tenants never hit this —
their software returns ready slots.
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from gogo.domain import Slot
from gogo.hours import day_intervals, iter_days
QUANTIZE_MIN = 15 # slot starts snap to :00/:15/:30/:45
def subtract_busy(
interval: tuple[datetime, datetime], busy: list[tuple[datetime, datetime]]
) -> list[tuple[datetime, datetime]]:
"""Subtract busy blocks from one open interval → list of free intervals."""
free = [interval]
for b_start, b_end in sorted(busy):
next_free = []
for f_start, f_end in free:
if b_end <= f_start or b_start >= f_end:
next_free.append((f_start, f_end))
continue
if b_start > f_start:
next_free.append((f_start, b_start))
if b_end < f_end:
next_free.append((b_end, f_end))
free = next_free
return free
def compute_slots(
*,
working_hours: dict,
busy: list[tuple[datetime, datetime]],
duration_min: int,
buffer_min: int = 0,
date_from: date,
date_to: date,
tz: ZoneInfo,
now: datetime,
min_notice_hours: int = 2,
max_days_ahead: int = 14,
max_slots: int = 20,
) -> list[Slot]:
"""Generate offerable free slots, earliest first."""
earliest_start = now + timedelta(hours=min_notice_hours)
horizon = (now + timedelta(days=max_days_ahead)).date()
date_to = min(date_to, horizon)
total_min = duration_min + buffer_min
step = timedelta(minutes=total_min) # spec §8.1: quantized to service duration (+ buffer)
slots: list[Slot] = []
for day in iter_days(date_from, date_to):
for open_start, open_end in day_intervals(working_hours, day, tz):
for f_start, f_end in subtract_busy((open_start, open_end), busy):
cursor = _quantize_up(max(f_start, earliest_start.astimezone(tz)))
while cursor + timedelta(minutes=total_min) <= f_end:
slots.append(Slot(start=cursor, end=cursor + timedelta(minutes=duration_min)))
if len(slots) >= max_slots:
return slots
cursor += step
return slots
def _quantize_up(dt: datetime) -> datetime:
dt = dt.replace(second=0, microsecond=0)
rem = dt.minute % QUANTIZE_MIN
if rem:
dt += timedelta(minutes=QUANTIZE_MIN - rem)
return dt