- 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>
360 lines
16 KiB
Python
360 lines
16 KiB
Python
"""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
|
|
)
|