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:
359
gogo/models.py
Normal file
359
gogo/models.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""ORM models — core tables per spec §13."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
Uuid,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from gogo.db import Base
|
||||
|
||||
JSONVariant = JSON().with_variant(JSONB(), "postgresql")
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow
|
||||
)
|
||||
|
||||
|
||||
class Tenant(Base, TimestampMixin):
|
||||
"""A salon."""
|
||||
|
||||
__tablename__ = "tenants"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
slug: Mapped[str] = mapped_column(String(80), unique=True) # short id used in URLs
|
||||
address: Mapped[str] = mapped_column(String(300), default="")
|
||||
city: Mapped[str] = mapped_column(String(100), default="")
|
||||
phone: Mapped[str] = mapped_column(String(40), default="") # salon's own public number
|
||||
website: Mapped[str] = mapped_column(String(200), default="")
|
||||
|
||||
locale: Mapped[str] = mapped_column(String(20), default="bs-Latn-BA")
|
||||
timezone: Mapped[str] = mapped_column(String(50), default="Europe/Sarajevo")
|
||||
currency: Mapped[str] = mapped_column(String(10), default="BAM")
|
||||
|
||||
# {"mon": [["09:00","13:00"],["14:00","18:00"]], ..., "sun": []}
|
||||
working_hours: Mapped[dict] = mapped_column(JSONVariant, default=dict)
|
||||
|
||||
scheduling_provider: Mapped[str] = mapped_column(String(30), default="google_calendar")
|
||||
|
||||
# Plan / metering (§12)
|
||||
plan: Mapped[str] = mapped_column(String(40), default="gogo_start")
|
||||
included_minutes: Mapped[int] = mapped_column(Integer, default=300)
|
||||
hard_cutoff: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
paid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="active") # active | disabled
|
||||
|
||||
# Agent config (§10.5)
|
||||
tts_voice: Mapped[str] = mapped_column(String(80), default="")
|
||||
price_mode: Mapped[str] = mapped_column(String(20), default="exact") # exact|range|on_request
|
||||
agent_notes: Mapped[str] = mapped_column(Text, default="") # plain informational notes
|
||||
llm_model: Mapped[str] = mapped_column(String(80), default="") # empty = global default
|
||||
|
||||
# Proposal engine
|
||||
proposal_ttl_hours: Mapped[int] = mapped_column(Integer, default=24)
|
||||
notify_emails: Mapped[list] = mapped_column(JSONVariant, default=list) # owner emails
|
||||
sms_request_received: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
sms_templates: Mapped[dict] = mapped_column(JSONVariant, default=dict) # overrides only
|
||||
|
||||
# Google Calendar tenants
|
||||
min_notice_hours: Mapped[int] = mapped_column(Integer, default=2)
|
||||
max_days_ahead: Mapped[int] = mapped_column(Integer, default=14)
|
||||
|
||||
# Telephony
|
||||
ring_first_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
ring_timeout_s: Mapped[int] = mapped_column(Integer, default=12)
|
||||
ring_strategy: Mapped[str] = mapped_column(String(20), default="ring_all") # ring_all|sequential
|
||||
|
||||
# Chat widget
|
||||
widget_public_key: Mapped[str] = mapped_column(
|
||||
String(64), default=lambda: uuid.uuid4().hex, unique=True
|
||||
)
|
||||
|
||||
services: Mapped[list[Service]] = relationship(back_populates="tenant")
|
||||
|
||||
|
||||
class ProviderConfig(Base, TimestampMixin):
|
||||
__tablename__ = "provider_configs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), unique=True)
|
||||
provider_type: Mapped[str] = mapped_column(String(30))
|
||||
# partner_api: {base_url, api_key_encrypted, webhook_secret_encrypted,
|
||||
# catalog_sync, email_to_owner, polling_fallback}
|
||||
# google_calendar: {} (connection lives in calendar_connections)
|
||||
config: Mapped[dict] = mapped_column(JSONVariant, default=dict)
|
||||
|
||||
|
||||
class Service(Base, TimestampMixin):
|
||||
__tablename__ = "services"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
duration_min: Mapped[int] = mapped_column(Integer, default=30)
|
||||
price_min: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
price_max: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
home_visit: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
agent_note: Mapped[str] = mapped_column(Text, default="")
|
||||
buffer_min: Mapped[int] = mapped_column(Integer, default=0) # per-service buffer (GCal)
|
||||
partner_service_id: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
tenant: Mapped[Tenant] = relationship(back_populates="services")
|
||||
|
||||
|
||||
class CalendarConnection(Base, TimestampMixin):
|
||||
__tablename__ = "calendar_connections"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), unique=True)
|
||||
google_account: Mapped[str] = mapped_column(String(200))
|
||||
scopes: Mapped[list] = mapped_column(JSONVariant, default=list)
|
||||
token_data_encrypted: Mapped[str] = mapped_column(Text) # Fernet-encrypted OAuth tokens
|
||||
status: Mapped[str] = mapped_column(String(20), default="connected")
|
||||
|
||||
|
||||
class CalendarMapping(Base):
|
||||
"""service → google calendar id. A row with service_id NULL is the tenant default."""
|
||||
|
||||
__tablename__ = "calendar_mappings"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "service_id"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
service_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("services.id"), nullable=True
|
||||
)
|
||||
google_calendar_id: Mapped[str] = mapped_column(String(300))
|
||||
|
||||
|
||||
class Worker(Base, TimestampMixin):
|
||||
"""Ring-group member — exists only as a SIP endpoint, no user account (§5.3)."""
|
||||
|
||||
__tablename__ = "workers"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(100))
|
||||
sip_username: Mapped[str] = mapped_column(String(80), unique=True)
|
||||
sip_password: Mapped[str] = mapped_column(String(80)) # provisioned to softphone via QR
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
|
||||
class PhoneNumber(Base, TimestampMixin):
|
||||
"""Gogo SIM number assigned to a salon; maps to a GSM gateway port (§5.1)."""
|
||||
|
||||
__tablename__ = "phone_numbers"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("tenants.id"), nullable=True, unique=True
|
||||
)
|
||||
msisdn: Mapped[str] = mapped_column(String(30), unique=True)
|
||||
gateway_port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
operator: Mapped[str] = mapped_column(String(30), default="") # mtel|bhtelecom|eronet
|
||||
sim_status: Mapped[str] = mapped_column(String(20), default="unassigned")
|
||||
|
||||
|
||||
class Call(Base):
|
||||
__tablename__ = "calls"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
caller_msisdn: Mapped[str] = mapped_column(String(30), default="")
|
||||
duration_s: Mapped[int] = mapped_column(Integer, default=0)
|
||||
outcome: Mapped[str] = mapped_column(String(30), default="abandoned")
|
||||
recording_path: Mapped[str | None] = mapped_column(String(400), nullable=True)
|
||||
transcript: Mapped[list] = mapped_column(JSONVariant, default=list) # [{role, text, t}]
|
||||
agent_seconds_charged: Mapped[int] = mapped_column(Integer, default=0)
|
||||
trace: Mapped[dict] = mapped_column(JSONVariant, default=dict) # latency breakdown etc.
|
||||
|
||||
|
||||
class ChatSession(Base):
|
||||
__tablename__ = "chat_sessions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
resume_token: Mapped[str] = mapped_column(String(64), default=lambda: uuid.uuid4().hex)
|
||||
client_ip: Mapped[str] = mapped_column(String(60), default="")
|
||||
outcome: Mapped[str] = mapped_column(String(30), default="")
|
||||
|
||||
|
||||
class ChatMessage(Base):
|
||||
__tablename__ = "chat_messages"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("chat_sessions.id"), index=True)
|
||||
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
role: Mapped[str] = mapped_column(String(20)) # user | assistant | tool
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
|
||||
|
||||
class BookingRequest(Base, TimestampMixin):
|
||||
__tablename__ = "booking_requests"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
source: Mapped[str] = mapped_column(String(10)) # voice | chat
|
||||
client_name: Mapped[str] = mapped_column(String(200))
|
||||
client_phone: Mapped[str] = mapped_column(String(40))
|
||||
service_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("services.id"), nullable=True)
|
||||
service_name_raw: Mapped[str] = mapped_column(String(300), default="")
|
||||
slots: Mapped[list] = mapped_column(JSONVariant, default=list) # ordered Slot dicts
|
||||
time_preference_text: Mapped[str] = mapped_column(String(300), default="")
|
||||
home_visit: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
address: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||
summary: Mapped[str] = mapped_column(Text, default="")
|
||||
status: Mapped[str] = mapped_column(String(30), default="pending", index=True)
|
||||
confirmed_slot: Mapped[dict | None] = mapped_column(JSONVariant, nullable=True)
|
||||
partner_request_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
call_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("calls.id"), nullable=True)
|
||||
chat_session_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("chat_sessions.id"), nullable=True
|
||||
)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
reminder_sent: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
resolution_note: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class ActionToken(Base):
|
||||
"""Signed single-use action link tokens for proposal emails (§9.1)."""
|
||||
|
||||
__tablename__ = "action_tokens"
|
||||
|
||||
token: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
request_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("booking_requests.id"), index=True)
|
||||
action: Mapped[str] = mapped_column(String(30)) # resolve | confirm | reject
|
||||
slot_index: Mapped[int | None] = mapped_column(Integer, nullable=True) # for confirm
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class MessageForOwner(Base):
|
||||
"""Non-booking messages taken by the agent (take_message tool)."""
|
||||
|
||||
__tablename__ = "messages_for_owner"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
client_name: Mapped[str] = mapped_column(String(200), default="")
|
||||
client_phone: Mapped[str] = mapped_column(String(40), default="")
|
||||
text: Mapped[str] = mapped_column(Text)
|
||||
source: Mapped[str] = mapped_column(String(10), default="voice")
|
||||
read: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
|
||||
class SmsLog(Base):
|
||||
__tablename__ = "sms_log"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
to_msisdn: Mapped[str] = mapped_column(String(30))
|
||||
body: Mapped[str] = mapped_column(Text)
|
||||
kind: Mapped[str] = mapped_column(String(30)) # missed_call|received|confirmation|rejection|expiry
|
||||
status: Mapped[str] = mapped_column(String(20), default="queued") # queued|sent|failed
|
||||
error: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class EmailLog(Base):
|
||||
__tablename__ = "email_log"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("tenants.id"), nullable=True)
|
||||
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
to_addr: Mapped[str] = mapped_column(String(300))
|
||||
subject: Mapped[str] = mapped_column(String(400))
|
||||
kind: Mapped[str] = mapped_column(String(30))
|
||||
status: Mapped[str] = mapped_column(String(20), default="queued")
|
||||
error: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class UsageCounter(Base):
|
||||
"""Per tenant per month usage (§12). month format: 'YYYY-MM'."""
|
||||
|
||||
__tablename__ = "usage_counters"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "month"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
month: Mapped[str] = mapped_column(String(7))
|
||||
agent_seconds: Mapped[int] = mapped_column(Integer, default=0)
|
||||
calls: Mapped[int] = mapped_column(Integer, default=0)
|
||||
sms_sent: Mapped[int] = mapped_column(Integer, default=0)
|
||||
chat_sessions: Mapped[int] = mapped_column(Integer, default=0)
|
||||
warned_80: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
warned_100: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
|
||||
class User(Base, TimestampMixin):
|
||||
"""Owner account — single account per salon (§10)."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), unique=True)
|
||||
email: Mapped[str] = mapped_column(String(200), unique=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(200))
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
|
||||
class Admin(Base, TimestampMixin):
|
||||
__tablename__ = "admins"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
email: Mapped[str] = mapped_column(String(200), unique=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(200))
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
|
||||
class PromptTemplate(Base):
|
||||
"""Versioned global agent prompt template, super-admin owned (§6.3)."""
|
||||
|
||||
__tablename__ = "prompt_templates"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
version: Mapped[int] = mapped_column(Integer, unique=True)
|
||||
body: Mapped[str] = mapped_column(Text)
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
published: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class TenantPromptOverride(Base):
|
||||
"""Per-tenant prompt override — super-admin-only escape hatch (§6.3)."""
|
||||
|
||||
__tablename__ = "tenant_prompt_overrides"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), unique=True)
|
||||
body: Mapped[str] = mapped_column(Text)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow
|
||||
)
|
||||
Reference in New Issue
Block a user