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:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
*.egg-info/
|
||||
dist/
|
||||
recordings/
|
||||
*.db
|
||||
12
Dockerfile
Normal file
12
Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md* ./
|
||||
COPY gogo ./gogo
|
||||
COPY alembic ./alembic
|
||||
COPY alembic.ini ./
|
||||
|
||||
RUN pip install --no-cache-dir -e .
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "gogo.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
55
README.md
Normal file
55
README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Gogo Telefon
|
||||
|
||||
Voice-first AI receptionist for small service businesses in Bosnia and Herzegovina.
|
||||
The agent answers forwarded calls (and web chat), speaks Bosnian, checks real
|
||||
availability and sends **booking proposals** to the salon owner — it never books
|
||||
appointments itself. See `docs/` and the product spec for details.
|
||||
|
||||
## Architecture (short version)
|
||||
|
||||
```
|
||||
caller → salon number (conditional forwarding) → GSM gateway → Asterisk
|
||||
→ ring group (staff softphones) → no answer → voice agent (Pipecat)
|
||||
→ tools HTTP → FastAPI backend → SchedulingProvider (Google Calendar | Partner API)
|
||||
→ proposal email / partner push → owner action → (optional) SMS to client
|
||||
```
|
||||
|
||||
Everything self-hosted except LLM + TTS APIs (swappable interfaces).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
uv venv && uv pip install -e '.[dev]'
|
||||
docker compose up -d db # Postgres 16 on localhost:5433 (or use your own)
|
||||
export GOGO_DATABASE_URL=postgresql+psycopg://gogo:gogo@localhost:5432/gogo
|
||||
.venv/bin/alembic upgrade head
|
||||
.venv/bin/uvicorn gogo.main:app --reload
|
||||
```
|
||||
|
||||
Run tests (SQLite, no services needed):
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m pytest
|
||||
```
|
||||
|
||||
The fake partner booking API used in tests (`tests/fake_partner.py`) doubles as
|
||||
the **reference implementation of Appendix B** for partner IT teams:
|
||||
|
||||
```bash
|
||||
.venv/bin/uvicorn tests.fake_partner:app --port 9100
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `gogo/scheduling/` | `SchedulingProvider` interface + `google_calendar`, `partner_api`, `mock` |
|
||||
| `gogo/proposals/` | proposal engine, action tokens, owner emails, .ics |
|
||||
| `gogo/sms/` | `SmsProvider` (console now, GSM gateway in M5) + Bosnian templates |
|
||||
| `gogo/api/` | action links, partner webhook, health |
|
||||
| `gogo/agent/` | LLM agent: prompt composition, tools, conversation loop (M2) |
|
||||
| `gogo/voice/` | Pipecat voice pipeline (M4) |
|
||||
| `gogo/telephony/` | Asterisk/GSM/forwarding codes (M4/M5) |
|
||||
| `tests/fake_partner.py` | Appendix B reference implementation |
|
||||
|
||||
All code/comments in English; all client- and owner-facing text in Bosnian (Latin).
|
||||
37
alembic.ini
Normal file
37
alembic.ini
Normal file
@@ -0,0 +1,37 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
50
alembic/env.py
Normal file
50
alembic/env.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import re
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from gogo.config import get_settings
|
||||
from gogo.db import Base
|
||||
from gogo import models # noqa: F401 — register all tables
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def _sync_url() -> str:
|
||||
# alembic runs sync; strip async driver suffixes
|
||||
url = get_settings().database_url
|
||||
return re.sub(r"\+(asyncpg|aiosqlite)", "", url)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=_sync_url(),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
{"sqlalchemy.url": _sync_url()},
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
25
alembic/script.py.mako
Normal file
25
alembic/script.py.mako
Normal file
@@ -0,0 +1,25 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
352
alembic/versions/62fcb10345fa_initial_schema.py
Normal file
352
alembic/versions/62fcb10345fa_initial_schema.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 62fcb10345fa
|
||||
Revises:
|
||||
Create Date: 2026-07-11 09:43:04.542280
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = '62fcb10345fa'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('admins',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('email', sa.String(length=200), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=200), nullable=False),
|
||||
sa.Column('active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('email')
|
||||
)
|
||||
op.create_table('prompt_templates',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('version', sa.Integer(), nullable=False),
|
||||
sa.Column('body', sa.Text(), nullable=False),
|
||||
sa.Column('notes', sa.Text(), nullable=False),
|
||||
sa.Column('published', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('version')
|
||||
)
|
||||
op.create_table('tenants',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('name', sa.String(length=200), nullable=False),
|
||||
sa.Column('slug', sa.String(length=80), nullable=False),
|
||||
sa.Column('address', sa.String(length=300), nullable=False),
|
||||
sa.Column('city', sa.String(length=100), nullable=False),
|
||||
sa.Column('phone', sa.String(length=40), nullable=False),
|
||||
sa.Column('website', sa.String(length=200), nullable=False),
|
||||
sa.Column('locale', sa.String(length=20), nullable=False),
|
||||
sa.Column('timezone', sa.String(length=50), nullable=False),
|
||||
sa.Column('currency', sa.String(length=10), nullable=False),
|
||||
sa.Column('working_hours', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False),
|
||||
sa.Column('scheduling_provider', sa.String(length=30), nullable=False),
|
||||
sa.Column('plan', sa.String(length=40), nullable=False),
|
||||
sa.Column('included_minutes', sa.Integer(), nullable=False),
|
||||
sa.Column('hard_cutoff', sa.Boolean(), nullable=False),
|
||||
sa.Column('paid_until', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('tts_voice', sa.String(length=80), nullable=False),
|
||||
sa.Column('price_mode', sa.String(length=20), nullable=False),
|
||||
sa.Column('agent_notes', sa.Text(), nullable=False),
|
||||
sa.Column('llm_model', sa.String(length=80), nullable=False),
|
||||
sa.Column('proposal_ttl_hours', sa.Integer(), nullable=False),
|
||||
sa.Column('notify_emails', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False),
|
||||
sa.Column('sms_request_received', sa.Boolean(), nullable=False),
|
||||
sa.Column('sms_templates', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False),
|
||||
sa.Column('min_notice_hours', sa.Integer(), nullable=False),
|
||||
sa.Column('max_days_ahead', sa.Integer(), nullable=False),
|
||||
sa.Column('ring_first_enabled', sa.Boolean(), nullable=False),
|
||||
sa.Column('ring_timeout_s', sa.Integer(), nullable=False),
|
||||
sa.Column('ring_strategy', sa.String(length=20), nullable=False),
|
||||
sa.Column('widget_public_key', sa.String(length=64), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('slug'),
|
||||
sa.UniqueConstraint('widget_public_key')
|
||||
)
|
||||
op.create_table('calendar_connections',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('google_account', sa.String(length=200), nullable=False),
|
||||
sa.Column('scopes', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False),
|
||||
sa.Column('token_data_encrypted', sa.Text(), nullable=False),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('tenant_id')
|
||||
)
|
||||
op.create_table('calls',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('started_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('caller_msisdn', sa.String(length=30), nullable=False),
|
||||
sa.Column('duration_s', sa.Integer(), nullable=False),
|
||||
sa.Column('outcome', sa.String(length=30), nullable=False),
|
||||
sa.Column('recording_path', sa.String(length=400), nullable=True),
|
||||
sa.Column('transcript', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False),
|
||||
sa.Column('agent_seconds_charged', sa.Integer(), nullable=False),
|
||||
sa.Column('trace', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_calls_tenant_id'), 'calls', ['tenant_id'], unique=False)
|
||||
op.create_table('chat_sessions',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('started_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('resume_token', sa.String(length=64), nullable=False),
|
||||
sa.Column('client_ip', sa.String(length=60), nullable=False),
|
||||
sa.Column('outcome', sa.String(length=30), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_chat_sessions_tenant_id'), 'chat_sessions', ['tenant_id'], unique=False)
|
||||
op.create_table('email_log',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('to_addr', sa.String(length=300), nullable=False),
|
||||
sa.Column('subject', sa.String(length=400), nullable=False),
|
||||
sa.Column('kind', sa.String(length=30), nullable=False),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('error', sa.Text(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('messages_for_owner',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('client_name', sa.String(length=200), nullable=False),
|
||||
sa.Column('client_phone', sa.String(length=40), nullable=False),
|
||||
sa.Column('text', sa.Text(), nullable=False),
|
||||
sa.Column('source', sa.String(length=10), nullable=False),
|
||||
sa.Column('read', sa.Boolean(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_messages_for_owner_tenant_id'), 'messages_for_owner', ['tenant_id'], unique=False)
|
||||
op.create_table('phone_numbers',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('msisdn', sa.String(length=30), nullable=False),
|
||||
sa.Column('gateway_port', sa.Integer(), nullable=True),
|
||||
sa.Column('operator', sa.String(length=30), nullable=False),
|
||||
sa.Column('sim_status', sa.String(length=20), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('msisdn'),
|
||||
sa.UniqueConstraint('tenant_id')
|
||||
)
|
||||
op.create_table('provider_configs',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('provider_type', sa.String(length=30), nullable=False),
|
||||
sa.Column('config', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('tenant_id')
|
||||
)
|
||||
op.create_table('services',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('name', sa.String(length=200), nullable=False),
|
||||
sa.Column('duration_min', sa.Integer(), nullable=False),
|
||||
sa.Column('price_min', sa.Float(), nullable=True),
|
||||
sa.Column('price_max', sa.Float(), nullable=True),
|
||||
sa.Column('home_visit', sa.Boolean(), nullable=False),
|
||||
sa.Column('agent_note', sa.Text(), nullable=False),
|
||||
sa.Column('buffer_min', sa.Integer(), nullable=False),
|
||||
sa.Column('partner_service_id', sa.String(length=80), nullable=True),
|
||||
sa.Column('active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_services_tenant_id'), 'services', ['tenant_id'], unique=False)
|
||||
op.create_table('sms_log',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('to_msisdn', sa.String(length=30), nullable=False),
|
||||
sa.Column('body', sa.Text(), nullable=False),
|
||||
sa.Column('kind', sa.String(length=30), nullable=False),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('error', sa.Text(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_sms_log_tenant_id'), 'sms_log', ['tenant_id'], unique=False)
|
||||
op.create_table('tenant_prompt_overrides',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('body', sa.Text(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('tenant_id')
|
||||
)
|
||||
op.create_table('usage_counters',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('month', sa.String(length=7), nullable=False),
|
||||
sa.Column('agent_seconds', sa.Integer(), nullable=False),
|
||||
sa.Column('calls', sa.Integer(), nullable=False),
|
||||
sa.Column('sms_sent', sa.Integer(), nullable=False),
|
||||
sa.Column('chat_sessions', sa.Integer(), nullable=False),
|
||||
sa.Column('warned_80', sa.Boolean(), nullable=False),
|
||||
sa.Column('warned_100', sa.Boolean(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('tenant_id', 'month')
|
||||
)
|
||||
op.create_index(op.f('ix_usage_counters_tenant_id'), 'usage_counters', ['tenant_id'], unique=False)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('email', sa.String(length=200), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=200), nullable=False),
|
||||
sa.Column('active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('email'),
|
||||
sa.UniqueConstraint('tenant_id')
|
||||
)
|
||||
op.create_table('workers',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('sip_username', sa.String(length=80), nullable=False),
|
||||
sa.Column('sip_password', sa.String(length=80), nullable=False),
|
||||
sa.Column('active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('sip_username')
|
||||
)
|
||||
op.create_index(op.f('ix_workers_tenant_id'), 'workers', ['tenant_id'], unique=False)
|
||||
op.create_table('booking_requests',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('source', sa.String(length=10), nullable=False),
|
||||
sa.Column('client_name', sa.String(length=200), nullable=False),
|
||||
sa.Column('client_phone', sa.String(length=40), nullable=False),
|
||||
sa.Column('service_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('service_name_raw', sa.String(length=300), nullable=False),
|
||||
sa.Column('slots', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False),
|
||||
sa.Column('time_preference_text', sa.String(length=300), nullable=False),
|
||||
sa.Column('home_visit', sa.Boolean(), nullable=False),
|
||||
sa.Column('address', sa.String(length=300), nullable=True),
|
||||
sa.Column('summary', sa.Text(), nullable=False),
|
||||
sa.Column('status', sa.String(length=30), nullable=False),
|
||||
sa.Column('confirmed_slot', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True),
|
||||
sa.Column('partner_request_id', sa.String(length=120), nullable=True),
|
||||
sa.Column('call_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('chat_session_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('reminder_sent', sa.Boolean(), nullable=False),
|
||||
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('resolution_note', sa.Text(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['call_id'], ['calls.id'], ),
|
||||
sa.ForeignKeyConstraint(['chat_session_id'], ['chat_sessions.id'], ),
|
||||
sa.ForeignKeyConstraint(['service_id'], ['services.id'], ),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_booking_requests_status'), 'booking_requests', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_booking_requests_tenant_id'), 'booking_requests', ['tenant_id'], unique=False)
|
||||
op.create_table('calendar_mappings',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('service_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('google_calendar_id', sa.String(length=300), nullable=False),
|
||||
sa.ForeignKeyConstraint(['service_id'], ['services.id'], ),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('tenant_id', 'service_id')
|
||||
)
|
||||
op.create_index(op.f('ix_calendar_mappings_tenant_id'), 'calendar_mappings', ['tenant_id'], unique=False)
|
||||
op.create_table('chat_messages',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('session_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('role', sa.String(length=20), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['session_id'], ['chat_sessions.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_chat_messages_session_id'), 'chat_messages', ['session_id'], unique=False)
|
||||
op.create_table('action_tokens',
|
||||
sa.Column('token', sa.String(length=64), nullable=False),
|
||||
sa.Column('request_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('action', sa.String(length=30), nullable=False),
|
||||
sa.Column('slot_index', sa.Integer(), nullable=True),
|
||||
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['request_id'], ['booking_requests.id'], ),
|
||||
sa.PrimaryKeyConstraint('token')
|
||||
)
|
||||
op.create_index(op.f('ix_action_tokens_request_id'), 'action_tokens', ['request_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_action_tokens_request_id'), table_name='action_tokens')
|
||||
op.drop_table('action_tokens')
|
||||
op.drop_index(op.f('ix_chat_messages_session_id'), table_name='chat_messages')
|
||||
op.drop_table('chat_messages')
|
||||
op.drop_index(op.f('ix_calendar_mappings_tenant_id'), table_name='calendar_mappings')
|
||||
op.drop_table('calendar_mappings')
|
||||
op.drop_index(op.f('ix_booking_requests_tenant_id'), table_name='booking_requests')
|
||||
op.drop_index(op.f('ix_booking_requests_status'), table_name='booking_requests')
|
||||
op.drop_table('booking_requests')
|
||||
op.drop_index(op.f('ix_workers_tenant_id'), table_name='workers')
|
||||
op.drop_table('workers')
|
||||
op.drop_table('users')
|
||||
op.drop_index(op.f('ix_usage_counters_tenant_id'), table_name='usage_counters')
|
||||
op.drop_table('usage_counters')
|
||||
op.drop_table('tenant_prompt_overrides')
|
||||
op.drop_index(op.f('ix_sms_log_tenant_id'), table_name='sms_log')
|
||||
op.drop_table('sms_log')
|
||||
op.drop_index(op.f('ix_services_tenant_id'), table_name='services')
|
||||
op.drop_table('services')
|
||||
op.drop_table('provider_configs')
|
||||
op.drop_table('phone_numbers')
|
||||
op.drop_index(op.f('ix_messages_for_owner_tenant_id'), table_name='messages_for_owner')
|
||||
op.drop_table('messages_for_owner')
|
||||
op.drop_table('email_log')
|
||||
op.drop_index(op.f('ix_chat_sessions_tenant_id'), table_name='chat_sessions')
|
||||
op.drop_table('chat_sessions')
|
||||
op.drop_index(op.f('ix_calls_tenant_id'), table_name='calls')
|
||||
op.drop_table('calls')
|
||||
op.drop_table('calendar_connections')
|
||||
op.drop_table('tenants')
|
||||
op.drop_table('prompt_templates')
|
||||
op.drop_table('admins')
|
||||
# ### end Alembic commands ###
|
||||
38
docker-compose.yml
Normal file
38
docker-compose.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: gogo
|
||||
POSTGRES_PASSWORD: gogo
|
||||
POSTGRES_DB: gogo
|
||||
ports:
|
||||
- "127.0.0.1:5433:5432" # 5433 on the host to avoid clashing with a system postgres
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U gogo"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
app:
|
||||
build: .
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
GOGO_DATABASE_URL: postgresql+psycopg://gogo:gogo@db:5432/gogo
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
volumes:
|
||||
- recordings:/data/recordings
|
||||
command: >
|
||||
sh -c "alembic upgrade head &&
|
||||
uvicorn gogo.main:app --host 0.0.0.0 --port 8000"
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
recordings:
|
||||
606
docs/SPEC.md
Normal file
606
docs/SPEC.md
Normal file
@@ -0,0 +1,606 @@
|
||||
# Gogo Telefon — Product Specification (v1.0)
|
||||
|
||||
**Product name:** Gogo Telefon
|
||||
**Market:** Bosnia and Herzegovina (architecture must support later expansion to Serbia and Kosovo)
|
||||
**Spec status:** Draft v1.4 — living document agreed in planning session; open questions in §17
|
||||
**Intended reader:** Claude Code (implementation agent) and the founder
|
||||
|
||||
---
|
||||
|
||||
## 1. Product Overview
|
||||
|
||||
Gogo Telefon is a **voice-first AI receptionist** for small service businesses in Bosnia and Herzegovina (beauty salons, hairdressers, pedicure/manicure studios, and similar businesses with up to ~15 employees). These businesses lose bookings because nobody can answer the phone while working, and especially outside working hours.
|
||||
|
||||
The product:
|
||||
|
||||
1. **Voice agent** answers phone calls forwarded from the salon's existing number, speaks Bosnian/Serbian/Croatian, knows the salon's services and prices, checks real availability in the salon's scheduling system (Google Calendar or the salon's own booking software via Partner API), and collects a **booking request** from the caller.
|
||||
2. **Chat agent** — an embeddable web widget on the salon's website that does the same over text, using the same backend logic.
|
||||
3. **Crucially, the agent never books appointments itself.** It sends a **proposal** (booking request) to the owner by email. The primary flow: the owner contacts the client directly, arranges the appointment themselves, and marks the request as **resolved** with one click (no SMS is sent by us in that case). A secondary one-click option lets the owner confirm a proposed slot and have Gogo send the confirmation SMS to the client. Gogo never writes to the owner's calendar.
|
||||
4. **Humans first:** before the agent answers, the call rings the salon staff's softphones (SIP clients on their mobile phones). The agent picks up only if nobody answers, or immediately outside working hours.
|
||||
|
||||
**Business model:** flat rate ≈ **30 KM/month** per salon (target; annual prepay option ~300 KM/yr), with a fair-use limit on agent voice minutes (see §12). Payment is handled offline via B2B bank transfer — **no online payments / card processing in this product.**
|
||||
|
||||
**Distribution:** word of mouth. There is **no public sign-up.** A super-admin creates and configures every salon.
|
||||
|
||||
---
|
||||
|
||||
## 2. Key Design Principles
|
||||
|
||||
1. **Propose, don't book.** The agent gathers intent + availability and produces a structured proposal. The owner is always the decision-maker.
|
||||
2. **The owner keeps their tools.** Salon keeps its phone number (conditional call forwarding) and keeps its scheduling tool — Google Calendar (often multiple color-coded calendars per service type) or its own custom booking software (Gogo integrates via the Partner API, Appendix B). Proposals arrive where the owner already works: plain email, or directly inside their booking software. The dashboard is for setup and occasional review, not daily work.
|
||||
3. **Voice is the primary product.** Chat is a secondary channel sharing the same backend.
|
||||
4. **Own the infrastructure, minimize per-minute costs.** Self-hosted pipeline in the founder's own data center from day one. Only LLM and TTS remain paid APIs (both behind swappable interfaces).
|
||||
5. **Short calls by design.** Target call length 60–120 seconds. The agent is efficient and polite, not chatty.
|
||||
6. **Multi-country-ready foundations.** Currency, language, phone formats, and locale are per-tenant configuration, not hardcoded (BiH now; Serbia/Kosovo later).
|
||||
|
||||
---
|
||||
|
||||
## 3. System Architecture
|
||||
|
||||
```
|
||||
Caller (PSTN, salon's customers)
|
||||
│
|
||||
salon's own number (m:tel / BH Telecom / HT Eronet)
|
||||
│ conditional call forwarding
|
||||
│ (no answer / busy / out of hours)
|
||||
▼
|
||||
Our SIM number (per salon or pooled)
|
||||
│
|
||||
GSM Gateway (multi-SIM device,
|
||||
e.g. GoIP/Yeastar class) — also used
|
||||
for outbound SMS to clients
|
||||
│ SIP
|
||||
▼
|
||||
SIP Server (Asterisk or FreeSWITCH) — founder's DC
|
||||
│ dial-plan: ring group first (staff softphones,
|
||||
│ N seconds, within working hours only)
|
||||
│ no answer → route to agent
|
||||
▼
|
||||
Voice Pipeline (Pipecat, Python) — founder's DC
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ STT: faster-whisper (large-v3) on local GPU │
|
||||
│ LLM: API (Claude Haiku class) — swappable │
|
||||
│ TTS: API (Azure Neural or ElevenLabs Flash) │
|
||||
│ — swappable, decided by Phase-0 PoC │
|
||||
│ Turn-taking / barge-in: Pipecat built-ins │
|
||||
└──────────────────────────────────────────────┘
|
||||
│ tool calls (HTTP)
|
||||
▼
|
||||
Backend API (FastAPI, Python) — founder's DC
|
||||
┌──────────────┬───────────────┬──────────────┬───────────────┐
|
||||
│ Scheduling │ Proposal │ SMS sender │ Tenant config │
|
||||
│ providers: │ engine + │ (via GSM │ services, │
|
||||
│ GCal free/ │ email (.ics) │ gateway API) │ hours, limits │
|
||||
│ busy or │ or partner │ │ │
|
||||
│ Partner API │ push │ │ │
|
||||
└──────────────┴───────────────┴──────────────┴───────────────┘
|
||||
▲
|
||||
│ same tools API
|
||||
Chat Agent (web widget, WebSocket)
|
||||
▲
|
||||
Dashboard (owner) + Super-admin panel
|
||||
```
|
||||
|
||||
**Deployment:** everything runs in the founder's own data center. GPU node for Whisper (one RTX 4090 / L4-class GPU handles 10–15 concurrent calls). Docker Compose for MVP (no Kubernetes). PostgreSQL as the primary database.
|
||||
|
||||
---
|
||||
|
||||
## 4. Tech Stack
|
||||
|
||||
| Layer | Choice | Notes |
|
||||
|---|---|---|
|
||||
| Language | Python 3.12+ | one language across the whole repo |
|
||||
| Voice orchestration | Pipecat | open source; handles turn-taking, barge-in, SIP integration |
|
||||
| SIP server | Asterisk (or FreeSWITCH if Pipecat integration proves simpler) | dial-plan: ring group → agent fallback; call recording |
|
||||
| STT | faster-whisper, large-v3, on local GPU | language hint: `sr` (covers spoken bs/sr/hr); Phase-0 PoC validates |
|
||||
| LLM | Anthropic API, Haiku-class model | behind `LLMProvider` interface; model per-tenant configurable |
|
||||
| TTS | Azure Neural TTS **or** ElevenLabs Flash API | behind `TTSProvider` interface; Phase-0 PoC decides default voice |
|
||||
| Backend | FastAPI + SQLAlchemy + Alembic | REST + WebSocket (chat) |
|
||||
| DB | PostgreSQL 16 | |
|
||||
| Queue/scheduling | Built-in (arq or APScheduler) | proposal expiry, SMS dispatch retries |
|
||||
| Email | SMTP (configurable provider) + generated `.ics` attachments | one-click action links |
|
||||
| Dashboard | Server-rendered (FastAPI + Jinja2 + htmx) or lightweight React — implementer's choice, favor simplicity | Bosnian UI, Latin script for MVP |
|
||||
| Chat widget | Vanilla JS embeddable snippet + WebSocket | no framework requirements on salon's site |
|
||||
| Auth | Session-based; single account per salon + super-admin role | **no public registration** |
|
||||
|
||||
---
|
||||
|
||||
## 5. Telephony Layer
|
||||
|
||||
### 5.1 Inbound call path
|
||||
1. Salon activates **conditional call forwarding** on its own number: forward on no-answer (~15s), on busy, and (if operator supports schedules — usually not, so handled by us) out of hours. Dashboard shows per-operator activation codes (m:tel, BH Telecom, HT Eronet) generated for the salon's assigned Gogo number.
|
||||
2. Calls arrive at a **SIM in the GSM gateway** mapped to exactly one salon (MVP: 1 SIM = 1 salon; pooling with DID-style mapping is Phase 2).
|
||||
3. GSM gateway converts to SIP → Asterisk.
|
||||
|
||||
### 5.2 Dial-plan logic (per salon, driven by tenant config)
|
||||
```
|
||||
IF within salon working hours AND ring_group not empty AND ring_first_enabled:
|
||||
ring staff softphones (strategy: ring-all or sequential; default ring-all)
|
||||
timeout: configurable, default 12s
|
||||
IF answered by human → bridge call, record, tag outcome = "human_answered"
|
||||
ELSE → route to AI agent
|
||||
ELSE (out of hours, or ring group empty/disabled):
|
||||
route to AI agent immediately
|
||||
```
|
||||
|
||||
### 5.3 Staff softphones
|
||||
- Staff use **off-the-shelf SIP clients** (Linphone / Zoiper / Groundwire). We build nothing mobile for MVP.
|
||||
- Dashboard: owner adds a worker (name) → system generates SIP credentials + QR code / config link for easy setup.
|
||||
- Workers have **no accounts** in the product — they exist only as SIP endpoints in the ring group.
|
||||
|
||||
### 5.4 Call recording & disclosure
|
||||
- All agent calls are recorded (audio) and transcribed.
|
||||
- The agent's greeting **must include a recording disclosure**, e.g.:
|
||||
> "Dobar dan, dobili ste salon {naziv}. Ja sam Gogo, virtuelni asistent. Razgovor se snima. Kako vam mogu pomoći?"
|
||||
- Recordings retention: default 90 days (configurable), then delete audio, keep transcript.
|
||||
|
||||
### 5.5 SMS (outbound, via GSM gateway)
|
||||
- The same GSM gateway sends SMS through the salon's assigned SIM (effectively free within SIM plan).
|
||||
- MVP SMS use cases:
|
||||
1. **Missed-call SMS**: caller hung up before human or agent answered → "Poštovani, dobili ste {salon}. Možete zakazati i putem poruke ili chata: {link}. Nazvaćemo vas ili nas pozovite ponovo."
|
||||
2. **Request received** (optional, config): "Primili smo vaš zahtjev za termin. Javićemo vam potvrdu u najkraćem roku."
|
||||
3. **Confirmation**: "Potvrđen termin: {usluga}, {dan} {datum} u {vrijeme}h — {salon}."
|
||||
4. **Rejection / new proposal**: "{salon}: nažalost traženi termin nije moguć. {alternativa ili molba da pozovu}."
|
||||
- SMS templates are per-tenant editable (with safe defaults), always in Bosnian.
|
||||
|
||||
---
|
||||
|
||||
## 6. Voice Agent
|
||||
|
||||
### 6.1 Language & voice
|
||||
- Understands and speaks **Bosnian/Serbian/Croatian** (treated as one spoken language; STT language hint `sr`).
|
||||
- TTS voice: 2–3 curated voices to choose from in dashboard (final list decided by Phase-0 PoC).
|
||||
- Agent persona name: "Gogo". Tone: warm, brief, professional. No small talk beyond politeness.
|
||||
|
||||
### 6.2 Conversation goals (in order)
|
||||
1. Greet + recording disclosure (template with salon name).
|
||||
2. Identify caller's need: **(a)** book an appointment, **(b)** ask a question (prices, hours, location, services), **(c)** cancel/reschedule → MVP: agent takes a message for the owner (full flow is Phase 2), **(d)** other → take a message.
|
||||
3. For bookings, collect: **service** (match against tenant service list), **client name**, **callback phone number** (confirm digit by digit if unclear; default to caller ID with verbal confirmation), **home visit?** (only if the service is flagged as available at home — then also collect address/area), and **time preferences**.
|
||||
4. Call the availability tool (§8.2) and offer **up to 3 concrete free slots** consistent with the caller's preference. The caller may also state their own preferred time — the agent checks it, and if busy, offers nearest alternatives.
|
||||
5. Close explicitly: "Vaš zahtjev prosljeđujem salonu — kontaktiraće vas u najkraćem roku radi potvrde termina. Hvala i prijatno!"
|
||||
|
||||
### 6.3 Prompt composition (owners are never prompt engineers)
|
||||
- The agent's system prompt is built from a **global prompt template owned and maintained by the super-admin** (versioned; improving the template upgrades all salons at once).
|
||||
- The template contains placeholders filled from structured tenant data only: `{salon_profile}`, `{working_hours}`, `{services_table}`, `{notes}`, `{price_mode}`, `{greeting}`.
|
||||
- The greeting is auto-generated from the template + salon name (includes the recording disclosure). Owners do not edit prompt text anywhere.
|
||||
- **Per-tenant prompt overrides exist but are super-admin-only** (advanced escape hatch for unusual salons).
|
||||
- Owner-entered "notes" are plain informational fields injected as data, not free-form instructions to the model; the template instructs the model to treat them as facts about the salon.
|
||||
|
||||
### 6.4 Hard rules (system prompt requirements)
|
||||
- **Never state a booking as confirmed.** Always "zahtjev" / "prijedlog" until the owner approves.
|
||||
- Never invent services, prices, or free slots — only from tenant data and availability tool results.
|
||||
- Price answering mode per tenant config: exact prices / ranges only / "cijene na upit".
|
||||
- Include per-tenant custom notes in context (e.g., "ne primamo djecu ispod 7 godina", "parking iza zgrade").
|
||||
- Target call duration 60–120s; the agent steers back to the goal politely if the caller drifts.
|
||||
- If the caller is abusive or the agent cannot understand after 2 clarification attempts → apologize, promise a callback, end call, log a message for the owner.
|
||||
- Escalation to human mid-call: **Phase 2** (see §14).
|
||||
|
||||
### 6.5 Tools exposed to the agent (LLM function-calling)
|
||||
Identical for voice and chat:
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `get_salon_info()` | working hours, address, services with duration/price/notes, custom notes |
|
||||
| `check_availability(service_id, date_range, preference)` | returns free slots via the tenant's `SchedulingProvider` (§8): Google Calendar free/busy computation, or the partner's `GET /availability`; buffers applied for GCal tenants, partner tenants return ready slots |
|
||||
| `submit_booking_request(...)` | creates the proposal: service, client name, phone, 1–3 slots (ordered by client preference), home-visit flag + address, free-text summary of the request/problem |
|
||||
| `take_message(text, client_name, phone)` | for non-booking intents; emailed to owner |
|
||||
|
||||
---
|
||||
|
||||
## 7. Chat Agent (web widget)
|
||||
|
||||
- Embeddable JS snippet (one `<script>` tag + tenant public key) rendering a chat bubble.
|
||||
- Same system prompt, same tools, same proposal flow — text instead of voice.
|
||||
- Collects client's **phone number in chat** (required for confirmation SMS).
|
||||
- Rate-limited per IP; simple anti-spam (honeypot + optional Turnstile).
|
||||
- Chat transcripts appear in the dashboard alongside call history.
|
||||
- No client accounts; ephemeral session, resumable via localStorage token for 24h.
|
||||
|
||||
---
|
||||
|
||||
## 8. Scheduling Providers (pluggable — core MVP abstraction)
|
||||
|
||||
Availability lookup and proposal delivery are abstracted behind a **`SchedulingProvider` interface**, selected and configured **per tenant by the super-admin**. MVP ships two implementations. Adding a third provider must require no changes to the agent, tools, or proposal engine.
|
||||
|
||||
```python
|
||||
class SchedulingProvider(Protocol):
|
||||
def get_services(self) -> list[Service] | None # optional catalog sync
|
||||
def get_availability(self, service_id, date_from, date_to) -> list[Slot]
|
||||
def deliver_request(self, booking_request) -> DeliveryResult
|
||||
# provider signals request outcome back via callback/webhook or is
|
||||
# closed manually by the owner (email actions / dashboard)
|
||||
```
|
||||
|
||||
### 8.1 Provider A — Google Calendar (`google_calendar`)
|
||||
- OAuth 2.0 (Google), initiated from the dashboard by the owner (or super-admin during onboarding, on the owner's device/account).
|
||||
- **Read (free/busy) scope only.** Gogo never requests calendar write access and never creates/modifies events.
|
||||
- A salon maps **each service (or service group) to one calendar** — this mirrors the common real-world pattern of separate color-coded calendars per service type. Default: single primary calendar for everything.
|
||||
- **Availability logic:** free/busy computed against the mapped calendar; slot generation = working hours minus busy blocks, quantized to service duration (+ optional per-service buffer minutes); tenant buffers `min_notice_hours` (default 2) and `max_days_ahead` (default 14).
|
||||
- **Proposal delivery:** email to owner with action links + `.ics` (§9).
|
||||
- **Sync without write access:** the owner enters appointments into their own calendar (directly or via the `.ics`); free/busy reads then automatically reflect them in the slots the agent offers. If the owner uses the "confirm + SMS" action, the backend re-checks free/busy first and warns if the slot has since been taken.
|
||||
|
||||
### 8.2 Provider B — Partner API (`partner_api`)
|
||||
For salons that run their own custom booking software (the first two customers are in this category). Their software is the **source of truth for both availability and requests**:
|
||||
|
||||
- **Availability:** Gogo calls the partner's HTTP endpoint for free slots — no slot computation on our side, the partner returns ready-to-offer slots.
|
||||
- **Proposal delivery:** Gogo **pushes the booking request into the partner's software** via HTTP; salon staff handle it in the tool they already use. Email to the owner is optional per tenant (default off for partner-API tenants) — the partner system is the inbox.
|
||||
- **Request outcome:** the partner system calls our webhook when staff resolve/confirm/reject the request, which drives the client SMS and closes the request in Gogo. If no webhook is feasible, super-admin can enable polling mode as fallback.
|
||||
- **Service catalog:** optionally synced from the partner (`GET /services`) so the owner maintains services in one place; otherwise entered in the Gogo dashboard as usual.
|
||||
- The exact HTTP contract the partner's IT team must implement is specified in **Appendix B** — it is deliberately minimal (2 required endpoints + 1 webhook).
|
||||
|
||||
### 8.3 Super-admin provider configuration (per tenant)
|
||||
- Provider type: `google_calendar` | `partner_api`.
|
||||
- For `partner_api`: base URL, API key (ours → theirs), webhook shared secret (theirs → ours), catalog sync on/off, email-to-owner on/off, polling fallback on/off.
|
||||
- Connectivity test button: runs a live `get_availability` probe and a no-op request delivery (dry-run flag, see Appendix B) and shows the raw response — essential for debugging partner integrations during onboarding.
|
||||
|
||||
---
|
||||
|
||||
## 9. Proposal Engine (the core flow)
|
||||
|
||||
### 9.1 Creation
|
||||
`submit_booking_request` creates a `BookingRequest` with status `pending` and delivers it **through the tenant's scheduling provider** (§8):
|
||||
|
||||
- **`google_calendar` tenants** → email to owner (below).
|
||||
- **`partner_api` tenants** → HTTP push into the partner's software (Appendix B); email optional per tenant config. Outcome arrives via the partner's webhook (or polling fallback) and triggers the same state transitions and client SMS as the email actions do.
|
||||
|
||||
**Email to owner** (one or more configured addresses):
|
||||
- Subject: `Novi zahtjev za termin — {Usluga} — {Ime} ({dan/i})`
|
||||
- Body: client name, phone, service, requested slots (ordered), home-visit info if any, short conversation summary, link to full transcript in dashboard.
|
||||
- **Action buttons** (signed, single-use links to backend, no login required):
|
||||
`[Riješeno — kontaktirao sam klijenta]` *(primary)*
|
||||
`[Potvrdi {slot 1} + pošalji SMS] [Potvrdi {slot 2} + pošalji SMS] ...` *(secondary convenience)*
|
||||
`[Odbij]`
|
||||
- **`.ics` attachment** for the first-choice slot (METHOD:REQUEST) so Gmail offers "Add to calendar"; the owner can open, move, and rearrange the event freely before saving to their own calendar. Buttons remain the mechanism that closes the request in Gogo; `.ics` is convenience for calendar entry.
|
||||
|
||||
Regardless of provider: optional "request received" SMS to the client (per tenant config).
|
||||
|
||||
### 9.2 Owner actions
|
||||
- **Riješeno (primary):** owner has contacted the client directly and arranged everything themselves → status `resolved_by_owner`; **Gogo sends no SMS**; request is closed. The owner adds the appointment to their own calendar (manually or via the `.ics`), which free/busy reads then reflect automatically.
|
||||
- **Confirm slot N + SMS (secondary):** for owners who prefer one click over calling back → backend re-checks free/busy (warns if slot taken), status `confirmed`, confirmation SMS to client. No calendar write — the owner still enters the event themselves.
|
||||
- **Reject** → status `rejected`; SMS to client (template, editable before send via a minimal web form on the action link).
|
||||
- **Expiry:** if no owner action within `proposal_ttl_hours` (default 24, configurable), status `expired`, client gets an apology SMS asking to call again. Reminder email to owner at 50% of TTL.
|
||||
|
||||
### 9.3 States
|
||||
`pending → resolved_by_owner | confirmed | rejected | expired`
|
||||
All transitions timestamped and visible in dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 10. Dashboard (owner) — MVP scope
|
||||
|
||||
Single account per salon (username/email + password, created by super-admin). Bosnian UI, Latin script.
|
||||
|
||||
**Settings**
|
||||
1. Salon profile: name, address, city, phone, working hours per weekday incl. breaks.
|
||||
2. Services: name, duration (min), price or price range, "home visit available" flag, note for the agent. CRUD table **plus "Zalijepi cjenovnik" import**: owner pastes their price list as free text (from website, Word, Facebook, anywhere) → LLM parses it into a structured services table → owner reviews/edits durations and confirms. Same paste-to-parse approach for working hours.
|
||||
3. Scheduling (contents depend on tenant's provider): for `google_calendar` — connect Google account (free/busy read only), map services → calendars, buffers (`min_notice_hours`, `max_days_ahead`); for `partner_api` — read-only view of connection status and last sync (all configuration is super-admin-only, §8.3).
|
||||
4. Telephony: assigned Gogo number; per-operator forwarding activation/deactivation codes; ring group management (add worker → name + generated SIP credentials + QR); ring timeout seconds; ring-first on/off.
|
||||
5. Agent: voice selection (2–3 curated options), price-answering mode (exact / ranges / on request), plain informational notes fields (e.g. "parking iza zgrade"); chat widget embed snippet. **No prompt or greeting editing** — the greeting and all agent behavior come from the super-admin-owned template (§6.3).
|
||||
6. Notifications: proposal email recipients; SMS template tweaks; "request received" SMS on/off.
|
||||
|
||||
**Review**
|
||||
1. Booking requests list with statuses + same action buttons as email.
|
||||
2. Call history: timestamp, caller number, duration, outcome (`human_answered / request_created / info_only / message_taken / abandoned`), transcript, audio player.
|
||||
3. Chat history (same format, text only).
|
||||
4. Usage: agent minutes this month vs. plan limit (simple progress bar).
|
||||
|
||||
Explicitly **out** of MVP dashboard: statistics/analytics, client CRM, multi-user roles, reminders configuration.
|
||||
|
||||
---
|
||||
|
||||
## 11. Super-admin panel (internal)
|
||||
|
||||
- Create/edit/disable salons (tenant config incl. plan and minute limit).
|
||||
- **Prompt template management:** edit the global agent prompt template (versioned, with rollback); per-tenant prompt overrides; test playground (run a text conversation against a tenant's composed prompt before publishing).
|
||||
- Assign SIM/number to salon; see GSM gateway port mapping.
|
||||
- Impersonate ("open dashboard as salon") for support and hands-on onboarding — expected primary onboarding path: **super-admin sets everything up for the owner** (word-of-mouth sales, "mi vam sve podesimo za 15 minuta").
|
||||
- Global usage overview: minutes, SMS count, per-salon costs (LLM+TTS estimated), proposals funnel.
|
||||
- System health: GPU/STT status, SIP registrations, gateway status, email/SMS delivery errors.
|
||||
|
||||
---
|
||||
|
||||
## 12. Plans, metering, limits
|
||||
|
||||
- Metering unit: **agent voice minutes** (human-answered ring-group time does NOT count; chat does not count toward voice minutes).
|
||||
- MVP plan (single plan): **"Gogo Start" — 30 KM/mj** (annual option ~300 KM), includes fair-use voice minutes: `included_minutes` per tenant, default **300 min/month** (tunable per tenant by super-admin).
|
||||
- Soft-limit behavior: at 80% → email to owner + banner in dashboard; at 100% → agent still answers (never silently drop customer calls) but super-admin is alerted for an upsell/limit conversation. Hard cutoff is a config flag, default off.
|
||||
- Billing itself is offline (bank transfer); the product only tracks plan, period, and usage. No invoicing module in MVP (super-admin marks "paid until" date; dashboard shows it).
|
||||
|
||||
---
|
||||
|
||||
## 13. Data model (core tables, indicative)
|
||||
|
||||
- `tenants` (salon): profile, locale (`bs-Latn-BA` default), currency (`BAM`), plan, included_minutes, paid_until, status, **scheduling_provider** (`google_calendar` | `partner_api`)
|
||||
- `provider_configs`: tenant_id, provider type, config jsonb (partner base URL, encrypted API key, webhook secret, sync/polling/email flags)
|
||||
- `services`: tenant_id, name, duration_min, price_min/price_max, home_visit bool, agent_note, calendar_id (FK to calendar mapping)
|
||||
- `calendar_connections`: tenant_id, google account, scopes, token data (encrypted)
|
||||
- `calendar_mappings`: service_id → google_calendar_id
|
||||
- `workers`: tenant_id, name, sip_username, sip_secret (hashed/managed), active bool
|
||||
- `phone_numbers`: tenant_id, msisdn, gateway_port, sim_status
|
||||
- `calls`: tenant_id, started_at, caller_msisdn, duration_s, outcome, recording_path, transcript, agent_minutes_charged
|
||||
- `chat_sessions` / `chat_messages`
|
||||
- `booking_requests`: tenant_id, source (voice/chat), client_name, client_phone, service_id, slots (jsonb, ordered), home_visit + address, summary, status, action tokens, timestamps
|
||||
- `messages_for_owner` (non-booking messages)
|
||||
- `sms_log`, `email_log`
|
||||
- `usage_counters` (per tenant per month)
|
||||
- `users` (owner accounts) + `admins`
|
||||
|
||||
---
|
||||
|
||||
## 14. Phasing
|
||||
|
||||
### Phase 0 — Proof of Concept (before any product code)
|
||||
Small scripts, ~1–2 days, kill the biggest risk first:
|
||||
1. **STT test:** transcribe recorded local speech samples via faster-whisper large-v3 (language `sr`): service names ("trajna", "pramenovi", "gel nokti", "depilacija"), personal names, and **phone numbers spoken aloud**. Measure practical accuracy.
|
||||
2. **TTS bake-off:** generate 5–6 typical agent sentences with Azure Neural (sr/hr/bs voices) and ElevenLabs (Flash v2.5 hr voice; v3-conversational sr/bs if latency acceptable). Founder listens, picks default voice(s); record latency and per-character cost.
|
||||
3. **End-to-end latency smoke test:** one Pipecat demo call through SIP (can be a softphone→Asterisk→Pipecat loop, no GSM needed yet) measuring mouth-to-ear response time. Target: < 1.5 s response latency.
|
||||
|
||||
**Gate:** if STT on phone numbers/names is unusable or latency > 2.5 s, stop and rethink providers before building.
|
||||
|
||||
### Phase 1 — MVP (everything in this spec except items below)
|
||||
Core call path (forwarding → GSM → SIP → ring group → agent), voice agent with tools, chat widget, **two scheduling providers (Google Calendar free/busy + Partner API per Appendix B)**, proposal engine with provider-routed delivery (email+.ics with "Riješeno" as primary, or push into partner software), SMS (missed-call, received, confirmation, rejection), owner dashboard, super-admin panel, metering.
|
||||
|
||||
### Phase 2 (explicitly out of MVP, design for it)
|
||||
- Mid-call transfer agent → human (retry ring group on request/failure)
|
||||
- SMS appointment reminders (day before)
|
||||
- Cancel/reschedule full flow through the agent
|
||||
- Inbound SMS handling ("DA" confirmations)
|
||||
- Owner statistics ("calls saved", out-of-hours volume) as retention/upsell tool
|
||||
- Additional `SchedulingProvider` implementations beyond the two MVP providers (Google Calendar, Partner API) — e.g. adapters for specific commercial booking tools, built on demand
|
||||
- Viber/WhatsApp channels
|
||||
- SIM pooling (many salons per SIM via signaling), multi-user accounts, Cyrillic UI toggle, Serbia/Kosovo locales
|
||||
|
||||
---
|
||||
|
||||
## 15. Non-functional requirements
|
||||
|
||||
- **Latency:** agent voice response < 1.5 s target, < 2.5 s worst case.
|
||||
- **Concurrency:** MVP sized for 10 concurrent calls (one GPU); architecture must scale by adding GPU workers.
|
||||
- **Privacy/GDPR-alignment:** recording disclosure in greeting; audio retention 90 days default; transcripts retained; client phone numbers used only for booking communication; per-tenant data isolation; encrypted OAuth tokens at rest; right-to-delete via super-admin.
|
||||
- **Reliability:** if the voice pipeline is down, Asterisk fallback: play a recorded message ("...pozovite kasnije ili posjetite {web}") and send the missed-call SMS. All owner-facing actions (email links) must be idempotent.
|
||||
- **Language:** all client- and owner-facing text in Bosnian (Latin). All code, comments, and docs in English.
|
||||
- **Observability:** structured logs, per-call trace (audio, transcript, tool calls, latency breakdown), basic health endpoints.
|
||||
|
||||
---
|
||||
|
||||
## 16. Implementation Plan for Claude Code
|
||||
|
||||
**Golden rule: everything that can run without hardware is built AND tested first.** Hardware-dependent code (GSM gateway, SIM, real operator forwarding) is written to completion but its live testing is explicitly deferred to a joint session with the founder. Milestones are ordered so each one is demonstrable on a laptop/DC with no telephony hardware.
|
||||
|
||||
### M0 — PoC scripts (Phase 0, §14)
|
||||
Standalone scripts, no product code: STT accuracy test (needs sample audio recordings from the founder), TTS bake-off, Pipecat latency smoke test using a **desktop softphone registered directly to Asterisk over the network** (no GSM needed). Deliverable: short results report + chosen default TTS voice. *(The latency test needs Asterisk running, which is software-only — include its Docker setup here.)*
|
||||
|
||||
### M1 — Backend core + proposal engine (pure software)
|
||||
FastAPI skeleton, DB schema/migrations, tenants/services/working-hours, `SchedulingProvider` interface with **three implementations: `google_calendar`, `partner_api`, and `mock` (in-memory, for tests and demos)**. Proposal engine end-to-end: create request → email with action links + `.ics` → owner actions → state transitions. SMS sending behind an `SmsProvider` interface with a `console/log` implementation (real GSM-gateway implementation written in M5).
|
||||
**Tests:** unit + integration (pytest); full proposal lifecycle against `mock` provider; email rendering snapshots; a **fake partner API server** (small FastAPI app in `tests/`) that implements Appendix B — used to test the `partner_api` provider including webhook signatures, idempotency replay, and polling fallback. This fake server doubles as a **reference implementation to hand to partner IT teams.**
|
||||
|
||||
### M2 — Agent logic in text mode
|
||||
Prompt composition from template + tenant data, tool definitions, conversation loop runnable as **text chat in the terminal/playground** (no voice). Implement the Appendix A scenarios as automated acceptance tests (assert tool calls, no "confirmed" language, ≤3 slots, closing line).
|
||||
**Tests:** all Appendix A scenarios green against `mock` and fake-partner providers.
|
||||
|
||||
### M3 — Chat widget + dashboard + super-admin panel
|
||||
Embeddable widget (WebSocket) reusing M2 agent, owner dashboard (all §10), super-admin panel (§11) incl. provider config, connectivity test (dry-run against fake partner server), prompt template editor + playground, "Zalijepi cjenovnik" paste-to-parse.
|
||||
**Tests:** widget e2e (Playwright or similar) booking flow → request appears in dashboard and email.
|
||||
|
||||
### M4 — Voice pipeline (software-only telephony)
|
||||
Asterisk + Pipecat + STT/TTS/LLM wired to the M2 agent tools. **Tested entirely with desktop/mobile softphones registering to Asterisk over IP** — this exercises the full real call path (SIP, audio, barge-in, recording, transcripts, metering) except the GSM leg. Ring-group-first dial-plan logic included and tested with two softphones (one as "worker", one as "caller").
|
||||
**Tests:** scripted call sessions; latency budget assertions; recording + transcript stored; minutes metered.
|
||||
|
||||
### M5 — Hardware integration layer (WRITE ONLY — live testing deferred)
|
||||
GSM gateway integration: SIP trunk config templates for the gateway, `SmsProvider` implementation for the gateway's HTTP SMS API, per-salon SIM/port mapping in super-admin, operator forwarding-code generator (m:tel / BH Telecom / HT Eronet). Everything written, code-reviewed, and covered by unit tests against a **mocked gateway API**; a `HARDWARE_TESTING.md` runbook is produced with the exact step-by-step live test plan (gateway setup, SIM insertion, forwarding activation, end-to-end call, SMS delivery) to execute together with the founder later.
|
||||
|
||||
### M6 — Hardening
|
||||
Fallback message when pipeline is down (§15), retention jobs, usage limit notifications, monitoring/health endpoints, backup strategy, deploy docs for the founder's DC (Docker Compose).
|
||||
|
||||
**Definition of done for the software phase:** a demo where a softphone call books a request end-to-end (agent conversation → availability from fake partner API → request pushed → webhook outcome → console-SMS logged), and the same flow via the chat widget with a Google-Calendar mock tenant — all without any hardware present.
|
||||
|
||||
---
|
||||
|
||||
## 17. Open questions (to resolve, not blockers for scaffolding)
|
||||
|
||||
1. **TTS default voice/provider** — decided by Phase-0 PoC (Azure vs ElevenLabs; quality vs cost).
|
||||
2. **Partner API adoption** — Appendix B is our proposed contract for the first two customers' custom booking software; awaiting their IT teams' review (possible adjustments to field names/flows, but the shape — availability pull + request push + outcome webhook — is fixed).
|
||||
3. **GSM gateway model** — founder to select hardware (GoIP/Yeastar class, SMS API capability required); spec assumes SIP + HTTP SMS API.
|
||||
4. **Operator terms for SIMs in gateways** — verify acceptable use with m:tel/BH Telecom/HT Eronet for inbound-only + SMS usage.
|
||||
5. **Recording consent wording** — confirm exact legally appropriate phrasing for BiH (both entities) before launch.
|
||||
6. **Exact plan limits** — 300 included minutes is a starting assumption; revisit after first pilot salons.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Golden conversation examples (voice agent)
|
||||
|
||||
These dialogues are the **behavioral gold standard** for the agent. They feed directly into the super-admin prompt template as few-shot examples and as acceptance criteria for testing. Editable by super-admin; the hard rules in §6.4 always take precedence. Fictional salon: "Salon Merima", Banja Luka.
|
||||
|
||||
**Style rules visible in all examples:** short sentences; one question at a time; numbers and times spoken naturally ("u dva i trideset", not "14:30h"); nikad "bukirano/potvrđeno", uvijek "zahtjev/prijedlog"; agent never fills silence with chatter.
|
||||
|
||||
### A.1 Happy path — booking with a preferred time
|
||||
|
||||
> **Gogo:** Dobar dan, dobili ste salon Merima. Ja sam Gogo, virtuelni asistent — razgovor se snima. Kako vam mogu pomoći?
|
||||
> **Klijent:** Dobar dan, htjela bih zakazati šišanje i feniranje.
|
||||
> **Gogo:** Može. Kada bi vam odgovaralo?
|
||||
> **Klijent:** Nešto u srijedu poslijepodne ako ima.
|
||||
> **Gogo:** *(tool: check_availability)* U srijedu poslijepodne slobodno je u dva i trideset ili u pet. Šta vam više odgovara?
|
||||
> **Klijent:** U pet.
|
||||
> **Gogo:** Važi. Na koje ime da zavedem zahtjev?
|
||||
> **Klijent:** Amra Hodžić.
|
||||
> **Gogo:** Hvala, Amra. Da li je broj s kojeg zovete pravi broj za kontakt?
|
||||
> **Klijent:** Jeste.
|
||||
> **Gogo:** Odlično. Prosljeđujem salonu zahtjev: šišanje i feniranje, srijeda u pet. Kontaktiraće vas u najkraćem roku radi potvrde. Hvala na pozivu i prijatno!
|
||||
|
||||
*Notes: agent offers max 2–3 slots, confirms caller-ID instead of asking to dictate the number, closes with the standard non-committal line. Target duration ~60s.*
|
||||
|
||||
### A.2 Price question only (no booking)
|
||||
|
||||
> **Gogo:** Dobar dan, dobili ste salon Merima. Ja sam Gogo, virtuelni asistent — razgovor se snima. Kako vam mogu pomoći?
|
||||
> **Klijent:** Koliko košta farbanje kose?
|
||||
> **Gogo:** Farbanje cijele dužine je od šezdeset do devedeset maraka, zavisno od dužine kose. Želite li da zakažemo termin?
|
||||
> **Klijent:** Ne, samo sam htjela cijenu. Hvala.
|
||||
> **Gogo:** Nema na čemu. Prijatno!
|
||||
|
||||
*Notes: answer from `{services_table}` respecting `{price_mode}` (here: ranges). One gentle booking offer, never a second. Call ends in ~20s — that's success, not failure.*
|
||||
|
||||
### A.3 Client unsure what they need
|
||||
|
||||
> **Klijent:** Uništila mi se kosa od peglanja, ne znam šta bi mi pomoglo...
|
||||
> **Gogo:** Razumijem. Salon radi keratinski tretman i dubinsku njegu — obje usluge su za oštećenu kosu. Ne mogu vam reći šta je bolje za vašu kosu, to će procijeniti frizerka. Mogu proslijediti zahtjev s napomenom da je kosa oštećena od peglanja, pa da vas salon nazove i posavjetuje. Može?
|
||||
> **Klijent:** Može, super.
|
||||
> **Gogo:** Kada vam uglavnom odgovara da dođete, radnim danom ili vikendom?
|
||||
> **Klijent:** Radnim danom poslije četiri.
|
||||
> **Gogo:** Zabilježeno. Vaše ime, molim?
|
||||
> ...
|
||||
|
||||
*Notes: agent NEVER gives beauty/treatment advice — it lists relevant services and routes the decision to the professional. The problem description goes into the request `summary`, which is exactly the "information about what the client wants or what problem they have" from the product definition.*
|
||||
|
||||
### A.4 Cancellation (MVP: message for the owner)
|
||||
|
||||
> **Klijent:** Trebala bih otkazati termin za sutra.
|
||||
> **Gogo:** Naravno. Recite mi vaše ime i u koliko sati je termin?
|
||||
> **Klijent:** Selma Kovač, sutra u deset.
|
||||
> **Gogo:** Hvala, Selma. Prosljeđujem salonu poruku da otkazujete sutrašnji termin u deset. Ako budete željeli novi termin, slobodno nazovite. Prijatno!
|
||||
|
||||
*Notes: MVP uses `take_message` — the agent does not touch the calendar and does not promise the slot is freed. Full reschedule flow is Phase 2.*
|
||||
|
||||
### A.5 Out-of-hours call
|
||||
|
||||
> **Gogo:** Dobar dan, dobili ste salon Merima. Ja sam Gogo, virtuelni asistent — razgovor se snima. Salon je trenutno zatvoren — radno vrijeme je od devet do osamnaest radnim danom i subotom do četrnaest. Mogu li vam pomoći da zakažete termin?
|
||||
> **Klijent:** Da, može manikir u petak.
|
||||
> **Gogo:** *(tool: check_availability)* U petak je slobodno u jedanaest i u petnaest i trideset...
|
||||
|
||||
*Notes: out-of-hours greeting variant states working hours proactively, then proceeds with the normal booking flow — this is the core product moment ("agent radi kad salon ne radi").*
|
||||
|
||||
### A.6 Difficult call — unclear speech, then graceful exit
|
||||
|
||||
> **Klijent:** *(nerazgovijetno / buka)*
|
||||
> **Gogo:** Izvinite, nisam vas dobro razumio. Možete li ponoviti?
|
||||
> **Klijent:** *(i dalje nerazumljivo)*
|
||||
> **Gogo:** Žao mi je, veza je loša i ne razumijem vas. Ostaviću salonu poruku da vas nazove na ovaj broj. Hvala na pozivu i prijatno!
|
||||
> *(tool: take_message — "nerazumljiv poziv, nazvati klijenta")*
|
||||
|
||||
*Notes: exactly two clarification attempts (per §6.4), then a promise of a callback and a clean, polite exit. The owner gets the caller's number either way — no call is ever fully "lost".*
|
||||
|
||||
### A.7 Home-visit service
|
||||
|
||||
> **Klijent:** Da li vi dolazite kući za pedikir? Majka mi je nepokretna.
|
||||
> **Gogo:** Da, salon radi pedikir na kućnoj adresi. U kojem ste dijelu grada?
|
||||
> **Klijent:** Lauš.
|
||||
> **Gogo:** Hvala. Kada bi vam odgovaralo da ekipa dođe?
|
||||
> **Klijent:** Bilo koje prijepodne ove sedmice.
|
||||
> **Gogo:** Zabilježeno — pedikir kod kuće, naselje Lauš, prijepodne ove sedmice. Vaše ime i da li je ovaj broj za kontakt?
|
||||
> ...
|
||||
|
||||
*Notes: only offered for services flagged `home_visit`; collects area/address and time window rather than exact slot (travel logistics are the owner's call). The request is marked `home_visit=true`.*
|
||||
|
||||
---
|
||||
|
||||
**Acceptance testing:** each example above becomes an automated test scenario (text-mode conversation against the composed prompt, asserting: correct tool calls, no "confirmed" language, ≤3 slots offered, closing line present). The super-admin playground (§11) runs the same scenarios on demand.
|
||||
|
||||
---
|
||||
|
||||
## Appendix B — Partner Scheduling API (spec for the partner's IT team)
|
||||
|
||||
This is the HTTP contract a salon's custom booking software must implement so Gogo Telefon can (1) read free slots and (2) push booking requests into it. It is deliberately minimal: **two endpoints on the partner side, one webhook on ours, plus one optional endpoint.** This document can be handed to the partner's IT team as-is.
|
||||
|
||||
### B.0 General conventions
|
||||
- **Transport:** HTTPS only. Partner exposes a base URL, e.g. `https://booking.salon-example.ba/gogo/v1`.
|
||||
- **Auth (Gogo → partner):** every request carries `Authorization: Bearer <api_key>` (key issued by the partner, stored by Gogo super-admin).
|
||||
- **Auth (partner → Gogo webhook):** HMAC-SHA256 signature of the raw body using a shared secret, sent as `X-Gogo-Signature: sha256=<hex>`.
|
||||
- **Format:** JSON (UTF-8). Timestamps: **ISO 8601 with timezone offset** (e.g. `2026-07-15T14:30:00+02:00`); salon timezone is `Europe/Sarajevo`.
|
||||
- **IDs:** all Gogo-issued IDs are opaque strings (UUID). Partner returns its own `partner_request_id`; both sides store the pair.
|
||||
- **Idempotency:** `POST /booking-requests` includes an `Idempotency-Key` header (the Gogo request UUID). Replays with the same key MUST return the original result, not create a duplicate.
|
||||
- **Errors:** non-2xx with body `{"error": {"code": "string", "message": "human readable"}}`. Gogo retries 5xx with exponential backoff (3 attempts); 4xx are not retried and are surfaced to super-admin monitoring.
|
||||
- **Latency requirement:** `GET /availability` should respond in **< 800 ms** — the voice agent is waiting on it mid-conversation. Everything else may be slower.
|
||||
|
||||
### B.1 `GET /availability` (required)
|
||||
Gogo asks for ready-to-offer free slots for one service in a date range. **The partner computes availability** (working hours, staff, existing bookings — all their logic); Gogo does no slot math for partner tenants.
|
||||
|
||||
Request:
|
||||
```
|
||||
GET /availability?service_id=SRV-12&from=2026-07-15&to=2026-07-22&home_visit=false
|
||||
Authorization: Bearer <api_key>
|
||||
```
|
||||
|
||||
Response `200`:
|
||||
```json
|
||||
{
|
||||
"service_id": "SRV-12",
|
||||
"slots": [
|
||||
{"start": "2026-07-15T14:30:00+02:00", "end": "2026-07-15T15:15:00+02:00",
|
||||
"staff_id": "EMP-3", "staff_name": "Merima"},
|
||||
{"start": "2026-07-15T17:00:00+02:00", "end": "2026-07-15T17:45:00+02:00"}
|
||||
]
|
||||
}
|
||||
```
|
||||
Notes: `staff_id`/`staff_name` optional (if present, the agent may say "kod Merime"). Empty `slots` array is a valid answer. Return at most ~20 slots; Gogo picks up to 3 to offer.
|
||||
|
||||
### B.2 `POST /booking-requests` (required)
|
||||
Gogo pushes a new booking request into the partner's software, where salon staff see and handle it in their usual workflow.
|
||||
|
||||
Request:
|
||||
```json
|
||||
POST /booking-requests
|
||||
Authorization: Bearer <api_key>
|
||||
Idempotency-Key: 7c9e6679-7425-40de-963d-...
|
||||
|
||||
{
|
||||
"gogo_request_id": "7c9e6679-7425-40de-963d-...",
|
||||
"created_at": "2026-07-14T19:42:10+02:00",
|
||||
"source": "voice", // "voice" | "chat"
|
||||
"client": {"name": "Amra Hodžić", "phone": "+38765123456"},
|
||||
"service_id": "SRV-12", // null if the client couldn't be matched to a service
|
||||
"service_name_raw": "šišanje i feniranje",
|
||||
"requested_slots": [ // 0–3, ordered by client preference
|
||||
{"start": "2026-07-15T17:00:00+02:00", "end": "2026-07-15T17:45:00+02:00"}
|
||||
],
|
||||
"time_preference_text": "srijeda poslijepodne", // free text when no exact slot
|
||||
"home_visit": false,
|
||||
"address": null, // filled when home_visit = true
|
||||
"summary": "Klijentica želi šišanje i feniranje, preferira srijedu poslijepodne.",
|
||||
"transcript_url": "https://app.gogotelefon.ba/t/abc123", // auth-protected
|
||||
"dry_run": false // true = validate & discard (connectivity test)
|
||||
}
|
||||
```
|
||||
|
||||
Response `201`:
|
||||
```json
|
||||
{"partner_request_id": "REQ-8841", "status": "received"}
|
||||
```
|
||||
|
||||
### B.3 Webhook: request outcome (partner → Gogo, required)
|
||||
When staff resolve the request in the partner software, the partner calls Gogo's webhook. This is what triggers the client SMS and closes the request on our side.
|
||||
|
||||
```json
|
||||
POST https://app.gogotelefon.ba/webhooks/partner/{tenant_id}
|
||||
X-Gogo-Signature: sha256=<hmac of body>
|
||||
|
||||
{
|
||||
"gogo_request_id": "7c9e6679-...",
|
||||
"partner_request_id": "REQ-8841",
|
||||
"outcome": "confirmed", // "confirmed" | "resolved" | "rejected"
|
||||
"confirmed_slot": {"start": "2026-07-15T17:00:00+02:00",
|
||||
"end": "2026-07-15T17:45:00+02:00"}, // required when "confirmed"
|
||||
"note": "optional free text"
|
||||
}
|
||||
```
|
||||
Semantics (mirrors §9.2): `confirmed` → Gogo sends the confirmation SMS to the client; `resolved` → staff contacted the client themselves, **Gogo sends nothing**, request closed; `rejected` → Gogo sends the rejection SMS. Response: `200 {"ok": true}`. Gogo's webhook is idempotent per (`gogo_request_id`, `outcome`).
|
||||
|
||||
**Polling fallback (if the partner cannot call webhooks):** partner instead implements `GET /booking-requests/{partner_request_id}` returning `{"status": "pending" | "confirmed" | "resolved" | "rejected", "confirmed_slot": ...}`; Gogo polls every 5 minutes until terminal state or proposal TTL.
|
||||
|
||||
### B.4 `GET /services` (optional, recommended)
|
||||
Lets Gogo sync the service catalog so the salon maintains it in one place.
|
||||
```json
|
||||
{"services": [
|
||||
{"id": "SRV-12", "name": "Šišanje i feniranje", "duration_min": 45,
|
||||
"price_min": 25, "price_max": 35, "currency": "BAM",
|
||||
"home_visit": false, "active": true}
|
||||
]}
|
||||
```
|
||||
Synced nightly + on-demand from super-admin panel. If absent, services are maintained manually in the Gogo dashboard and `service_id` mapping is configured there.
|
||||
|
||||
### B.5 Partner checklist
|
||||
1. Issue an API key for Gogo; agree the base URL.
|
||||
2. Implement `GET /availability` (< 800 ms) and `POST /booking-requests` (idempotent).
|
||||
3. Implement the outcome webhook (or the polling endpoint) with the shared HMAC secret.
|
||||
4. Optionally implement `GET /services`.
|
||||
5. Joint test using Gogo's connectivity test (dry-run) from the super-admin panel.
|
||||
|
||||
|
||||
3
gogo/__init__.py
Normal file
3
gogo/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Gogo Telefon — voice-first AI receptionist for small service businesses."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
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}
|
||||
59
gogo/config.py
Normal file
59
gogo/config.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Application configuration via environment variables / .env file."""
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_prefix="GOGO_", extra="ignore")
|
||||
|
||||
# Core
|
||||
env: str = "dev" # dev | test | prod
|
||||
secret_key: str = "dev-secret-change-me"
|
||||
base_url: str = "http://localhost:8000" # public URL for action links / transcripts
|
||||
database_url: str = "postgresql+psycopg://gogo:gogo@localhost:5432/gogo"
|
||||
|
||||
# Email (SMTP). provider=console logs instead of sending.
|
||||
email_provider: str = "console" # console | smtp
|
||||
smtp_host: str = "localhost"
|
||||
smtp_port: int = 587
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_starttls: bool = True
|
||||
email_from: str = "Gogo Telefon <noreply@gogotelefon.ba>"
|
||||
|
||||
# SMS. provider=console logs instead of sending (GSM gateway impl in M5).
|
||||
sms_provider: str = "console" # console | gsm_gateway
|
||||
gsm_gateway_url: str = ""
|
||||
gsm_gateway_user: str = ""
|
||||
gsm_gateway_password: str = ""
|
||||
|
||||
# LLM
|
||||
anthropic_api_key: str = ""
|
||||
llm_model: str = "claude-haiku-4-5-20251001" # default; per-tenant override possible
|
||||
|
||||
# TTS (decided by Phase-0 PoC; both supported behind TTSProvider)
|
||||
tts_provider: str = "azure" # azure | elevenlabs
|
||||
azure_speech_key: str = ""
|
||||
azure_speech_region: str = "westeurope"
|
||||
elevenlabs_api_key: str = ""
|
||||
|
||||
# Google OAuth (Calendar free/busy)
|
||||
google_client_id: str = ""
|
||||
google_client_secret: str = ""
|
||||
|
||||
# Proposal engine defaults (overridable per tenant)
|
||||
proposal_ttl_hours: int = 24
|
||||
|
||||
# Retention
|
||||
audio_retention_days: int = 90
|
||||
|
||||
# Session cookies
|
||||
session_cookie: str = "gogo_session"
|
||||
session_max_age: int = 60 * 60 * 24 * 14 # 14 days
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
24
gogo/crypto.py
Normal file
24
gogo/crypto.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Symmetric encryption for secrets at rest (OAuth tokens, partner API keys).
|
||||
|
||||
Key is derived from GOGO_SECRET_KEY; rotate by re-encrypting after a key change.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from gogo.config import get_settings
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
key = hashlib.sha256(get_settings().secret_key.encode()).digest()
|
||||
return Fernet(base64.urlsafe_b64encode(key))
|
||||
|
||||
|
||||
def encrypt(plaintext: str) -> str:
|
||||
return _fernet().encrypt(plaintext.encode()).decode()
|
||||
|
||||
|
||||
def decrypt(ciphertext: str) -> str:
|
||||
return _fernet().decrypt(ciphertext.encode()).decode()
|
||||
43
gogo/db.py
Normal file
43
gogo/db.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Async SQLAlchemy engine/session setup."""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from gogo.config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
_engine = None
|
||||
_sessionmaker: async_sessionmaker[AsyncSession] | None = None
|
||||
|
||||
|
||||
def get_engine():
|
||||
global _engine, _sessionmaker
|
||||
if _engine is None:
|
||||
_engine = create_async_engine(get_settings().database_url, pool_pre_ping=True)
|
||||
_sessionmaker = async_sessionmaker(_engine, expire_on_commit=False)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
|
||||
get_engine()
|
||||
assert _sessionmaker is not None
|
||||
return _sessionmaker
|
||||
|
||||
|
||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||
"""FastAPI dependency."""
|
||||
async with get_sessionmaker()() as session:
|
||||
yield session
|
||||
|
||||
|
||||
def reset_engine() -> None:
|
||||
"""Test helper: force re-creation of the engine (e.g. after settings change)."""
|
||||
global _engine, _sessionmaker
|
||||
_engine = None
|
||||
_sessionmaker = None
|
||||
87
gogo/domain.py
Normal file
87
gogo/domain.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Shared domain value objects used across providers, agent tools and the proposal engine.
|
||||
|
||||
These are deliberately plain (pydantic) models decoupled from the ORM so that
|
||||
SchedulingProvider implementations and the agent can be tested without a database.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BookingStatus(enum.StrEnum):
|
||||
pending = "pending"
|
||||
resolved_by_owner = "resolved_by_owner" # owner contacted client directly (primary flow)
|
||||
confirmed = "confirmed" # owner confirmed a slot → Gogo sends confirmation SMS
|
||||
rejected = "rejected"
|
||||
expired = "expired"
|
||||
|
||||
|
||||
class CallOutcome(enum.StrEnum):
|
||||
human_answered = "human_answered"
|
||||
request_created = "request_created"
|
||||
info_only = "info_only"
|
||||
message_taken = "message_taken"
|
||||
abandoned = "abandoned"
|
||||
|
||||
|
||||
class ProviderType(enum.StrEnum):
|
||||
google_calendar = "google_calendar"
|
||||
partner_api = "partner_api"
|
||||
mock = "mock" # in-memory, for tests and demos
|
||||
|
||||
|
||||
class Slot(BaseModel):
|
||||
"""One concrete offerable time slot (timezone-aware datetimes)."""
|
||||
|
||||
start: datetime
|
||||
end: datetime
|
||||
staff_id: str | None = None
|
||||
staff_name: str | None = None
|
||||
|
||||
def model_post_init(self, __context) -> None:
|
||||
if self.start.tzinfo is None or self.end.tzinfo is None:
|
||||
raise ValueError("Slot datetimes must be timezone-aware")
|
||||
|
||||
|
||||
class ServiceInfo(BaseModel):
|
||||
"""Service as seen by providers / the agent (decoupled from ORM row)."""
|
||||
|
||||
id: str # our service UUID as string, or partner service id for catalog sync
|
||||
name: str
|
||||
duration_min: int
|
||||
price_min: float | None = None
|
||||
price_max: float | None = None
|
||||
currency: str = "BAM"
|
||||
home_visit: bool = False
|
||||
agent_note: str | None = None
|
||||
active: bool = True
|
||||
|
||||
|
||||
class BookingRequestData(BaseModel):
|
||||
"""Payload the proposal engine hands to a provider's deliver_request()."""
|
||||
|
||||
gogo_request_id: str
|
||||
tenant_id: str
|
||||
created_at: datetime
|
||||
source: str # "voice" | "chat"
|
||||
client_name: str
|
||||
client_phone: str
|
||||
service_id: str | None = None
|
||||
service_name_raw: str = ""
|
||||
requested_slots: list[Slot] = Field(default_factory=list) # 0-3, ordered by preference
|
||||
time_preference_text: str = ""
|
||||
home_visit: bool = False
|
||||
address: str | None = None
|
||||
summary: str = ""
|
||||
transcript_url: str = ""
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class DeliveryResult(BaseModel):
|
||||
ok: bool
|
||||
partner_request_id: str | None = None
|
||||
detail: str = ""
|
||||
1
gogo/email/__init__.py
Normal file
1
gogo/email/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from gogo.email.sender import EmailMessage, get_email_provider, send_email # noqa: F401
|
||||
92
gogo/email/sender.py
Normal file
92
gogo/email/sender.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""EmailProvider interface: SMTP for production, console for dev/tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from email.message import EmailMessage as MimeMessage
|
||||
from typing import Protocol
|
||||
|
||||
import aiosmtplib
|
||||
|
||||
from gogo.config import get_settings
|
||||
|
||||
log = logging.getLogger("gogo.email")
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailMessage:
|
||||
to: list[str]
|
||||
subject: str
|
||||
text: str
|
||||
html: str | None = None
|
||||
# attachments: (filename, mimetype, bytes)
|
||||
attachments: list[tuple[str, str, bytes]] = field(default_factory=list)
|
||||
|
||||
|
||||
class EmailProvider(Protocol):
|
||||
async def send(self, msg: EmailMessage) -> None: ...
|
||||
|
||||
|
||||
class ConsoleEmailProvider:
|
||||
"""Logs emails instead of sending; keeps them in memory for tests."""
|
||||
|
||||
sent: list[EmailMessage]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent = []
|
||||
|
||||
async def send(self, msg: EmailMessage) -> None:
|
||||
self.sent.append(msg)
|
||||
log.info(
|
||||
"EMAIL to=%s subject=%r attachments=%d\n%s",
|
||||
msg.to,
|
||||
msg.subject,
|
||||
len(msg.attachments),
|
||||
msg.text,
|
||||
)
|
||||
|
||||
|
||||
class SmtpEmailProvider:
|
||||
async def send(self, msg: EmailMessage) -> None:
|
||||
s = get_settings()
|
||||
mime = MimeMessage()
|
||||
mime["From"] = s.email_from
|
||||
mime["To"] = ", ".join(msg.to)
|
||||
mime["Subject"] = msg.subject
|
||||
mime.set_content(msg.text)
|
||||
if msg.html:
|
||||
mime.add_alternative(msg.html, subtype="html")
|
||||
for filename, mimetype, data in msg.attachments:
|
||||
maintype, subtype = mimetype.split("/", 1)
|
||||
mime.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
|
||||
await aiosmtplib.send(
|
||||
mime,
|
||||
hostname=s.smtp_host,
|
||||
port=s.smtp_port,
|
||||
username=s.smtp_user or None,
|
||||
password=s.smtp_password or None,
|
||||
start_tls=s.smtp_starttls,
|
||||
)
|
||||
|
||||
|
||||
_provider: EmailProvider | None = None
|
||||
|
||||
|
||||
def get_email_provider() -> EmailProvider:
|
||||
global _provider
|
||||
if _provider is None:
|
||||
_provider = (
|
||||
SmtpEmailProvider() if get_settings().email_provider == "smtp" else ConsoleEmailProvider()
|
||||
)
|
||||
return _provider
|
||||
|
||||
|
||||
def set_email_provider(p: EmailProvider | None) -> None:
|
||||
"""Test hook."""
|
||||
global _provider
|
||||
_provider = p
|
||||
|
||||
|
||||
async def send_email(msg: EmailMessage) -> None:
|
||||
await get_email_provider().send(msg)
|
||||
79
gogo/hours.py
Normal file
79
gogo/hours.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Working-hours helpers.
|
||||
|
||||
Working hours are stored per tenant as:
|
||||
{"mon": [["09:00", "13:00"], ["14:00", "18:00"]], ..., "sun": []}
|
||||
Multiple intervals per day model lunch breaks. Missing/empty day = closed.
|
||||
All computations happen in the tenant's timezone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
WEEKDAY_KEYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||||
|
||||
# Bosnian day names (Latin) for UI / agent context
|
||||
DAY_NAMES_BS = {
|
||||
"mon": "ponedjeljak",
|
||||
"tue": "utorak",
|
||||
"wed": "srijeda",
|
||||
"thu": "četvrtak",
|
||||
"fri": "petak",
|
||||
"sat": "subota",
|
||||
"sun": "nedjelja",
|
||||
}
|
||||
|
||||
DEFAULT_WORKING_HOURS: dict[str, list[list[str]]] = {
|
||||
"mon": [["09:00", "18:00"]],
|
||||
"tue": [["09:00", "18:00"]],
|
||||
"wed": [["09:00", "18:00"]],
|
||||
"thu": [["09:00", "18:00"]],
|
||||
"fri": [["09:00", "18:00"]],
|
||||
"sat": [["09:00", "14:00"]],
|
||||
"sun": [],
|
||||
}
|
||||
|
||||
|
||||
def _parse_hhmm(s: str) -> time:
|
||||
h, m = s.split(":")
|
||||
return time(int(h), int(m))
|
||||
|
||||
|
||||
def day_intervals(
|
||||
working_hours: dict, day: date, tz: ZoneInfo
|
||||
) -> list[tuple[datetime, datetime]]:
|
||||
"""Open intervals for a calendar day as tz-aware datetimes."""
|
||||
key = WEEKDAY_KEYS[day.weekday()]
|
||||
out = []
|
||||
for start_s, end_s in working_hours.get(key, []):
|
||||
start = datetime.combine(day, _parse_hhmm(start_s), tzinfo=tz)
|
||||
end = datetime.combine(day, _parse_hhmm(end_s), tzinfo=tz)
|
||||
if end > start:
|
||||
out.append((start, end))
|
||||
return out
|
||||
|
||||
|
||||
def is_open_at(working_hours: dict, at: datetime, tz: ZoneInfo) -> bool:
|
||||
at = at.astimezone(tz)
|
||||
return any(s <= at < e for s, e in day_intervals(working_hours, at.date(), tz))
|
||||
|
||||
|
||||
def hours_summary_bs(working_hours: dict) -> str:
|
||||
"""Human-readable Bosnian working-hours summary for prompts/emails."""
|
||||
parts = []
|
||||
for key in WEEKDAY_KEYS:
|
||||
intervals = working_hours.get(key, [])
|
||||
if not intervals:
|
||||
parts.append(f"{DAY_NAMES_BS[key]}: zatvoreno")
|
||||
else:
|
||||
spans = ", ".join(f"{a}–{b}" for a, b in intervals)
|
||||
parts.append(f"{DAY_NAMES_BS[key]}: {spans}")
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
def iter_days(start: date, end: date):
|
||||
d = start
|
||||
while d <= end:
|
||||
yield d
|
||||
d += timedelta(days=1)
|
||||
65
gogo/i18n.py
Normal file
65
gogo/i18n.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Bosnian (Latin) formatting helpers for client- and owner-facing text."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
DAY_NAMES = [
|
||||
"ponedjeljak",
|
||||
"utorak",
|
||||
"srijeda",
|
||||
"četvrtak",
|
||||
"petak",
|
||||
"subota",
|
||||
"nedjelja",
|
||||
]
|
||||
|
||||
MONTH_NAMES = [
|
||||
"januar",
|
||||
"februar",
|
||||
"mart",
|
||||
"april",
|
||||
"maj",
|
||||
"juni",
|
||||
"juli",
|
||||
"august",
|
||||
"septembar",
|
||||
"oktobar",
|
||||
"novembar",
|
||||
"decembar",
|
||||
]
|
||||
|
||||
|
||||
def fmt_slot(dt: datetime, tz: str = "Europe/Sarajevo") -> str:
|
||||
"""'srijeda, 15.07. u 17:00'"""
|
||||
local = dt.astimezone(ZoneInfo(tz))
|
||||
day = DAY_NAMES[local.weekday()]
|
||||
return f"{day}, {local.day:02d}.{local.month:02d}. u {local:%H:%M}"
|
||||
|
||||
|
||||
def fmt_date(dt: datetime, tz: str = "Europe/Sarajevo") -> str:
|
||||
local = dt.astimezone(ZoneInfo(tz))
|
||||
return f"{local.day:02d}.{local.month:02d}.{local.year}."
|
||||
|
||||
|
||||
def fmt_time(dt: datetime, tz: str = "Europe/Sarajevo") -> str:
|
||||
local = dt.astimezone(ZoneInfo(tz))
|
||||
return f"{local:%H:%M}"
|
||||
|
||||
|
||||
def fmt_price(price_min: float | None, price_max: float | None, currency: str = "KM") -> str:
|
||||
cur = "KM" if currency == "BAM" else currency
|
||||
if price_min is None and price_max is None:
|
||||
return "cijena na upit"
|
||||
if price_max is None or price_min == price_max:
|
||||
return f"{_num(price_min)} {cur}"
|
||||
if price_min is None:
|
||||
return f"do {_num(price_max)} {cur}"
|
||||
return f"{_num(price_min)}–{_num(price_max)} {cur}"
|
||||
|
||||
|
||||
def _num(x: float | None) -> str:
|
||||
if x is None:
|
||||
return "?"
|
||||
return str(int(x)) if float(x).is_integer() else f"{x:.2f}".replace(".", ",")
|
||||
110
gogo/jobs.py
Normal file
110
gogo/jobs.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Background jobs: proposal expiry/reminders, partner polling fallback, retention.
|
||||
|
||||
APScheduler (in-process) — no extra infra needed for MVP scale (§4).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from sqlalchemy import select
|
||||
|
||||
from gogo.db import get_sessionmaker
|
||||
from gogo.domain import BookingStatus, Slot
|
||||
from gogo.models import BookingRequest, Tenant
|
||||
from gogo.proposals import engine as proposal_engine
|
||||
from gogo.scheduling.base import get_provider
|
||||
|
||||
log = logging.getLogger("gogo.jobs")
|
||||
|
||||
|
||||
async def run_expiry_job() -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
expired = await proposal_engine.process_expirations(session)
|
||||
await session.commit()
|
||||
if expired:
|
||||
log.info("expired %d proposals", expired)
|
||||
|
||||
|
||||
async def run_partner_polling_job() -> None:
|
||||
"""Polling fallback (§B.3) for partner tenants that cannot call webhooks."""
|
||||
async with get_sessionmaker()() as session:
|
||||
pending = (
|
||||
(
|
||||
await session.execute(
|
||||
select(BookingRequest).where(
|
||||
BookingRequest.status == BookingStatus.pending.value,
|
||||
BookingRequest.partner_request_id.is_not(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for req in pending:
|
||||
tenant = (
|
||||
await session.execute(select(Tenant).where(Tenant.id == req.tenant_id))
|
||||
).scalar_one()
|
||||
if tenant.scheduling_provider != "partner_api":
|
||||
continue
|
||||
provider = await get_provider(session, tenant)
|
||||
if not provider.config.get("polling_fallback"):
|
||||
continue
|
||||
try:
|
||||
data = await provider.poll_status(req.partner_request_id)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("polling failed for request %s", req.id)
|
||||
continue
|
||||
status = data.get("status")
|
||||
if status == "confirmed" and data.get("confirmed_slot"):
|
||||
slot = Slot.model_validate(data["confirmed_slot"])
|
||||
await proposal_engine.confirm_request(session, tenant, req, slot, recheck=False)
|
||||
elif status == "resolved":
|
||||
await proposal_engine.resolve_request(session, tenant, req)
|
||||
elif status == "rejected":
|
||||
await proposal_engine.reject_request(session, tenant, req)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def run_retention_job() -> None:
|
||||
"""Delete call audio older than retention window; keep transcripts (§5.4)."""
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from gogo.config import get_settings
|
||||
from gogo.models import Call
|
||||
|
||||
cutoff = datetime.now(UTC) - timedelta(days=get_settings().audio_retention_days)
|
||||
async with get_sessionmaker()() as session:
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(Call).where(
|
||||
Call.recording_path.is_not(None), Call.started_at < cutoff
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for call in rows:
|
||||
path = call.recording_path
|
||||
if path and os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
log.exception("failed to delete recording %s", path)
|
||||
continue
|
||||
call.recording_path = None
|
||||
await session.commit()
|
||||
if rows:
|
||||
log.info("retention: cleared %d recordings", len(rows))
|
||||
|
||||
|
||||
def build_scheduler() -> AsyncIOScheduler:
|
||||
scheduler = AsyncIOScheduler()
|
||||
scheduler.add_job(run_expiry_job, "interval", minutes=5, id="proposal_expiry")
|
||||
scheduler.add_job(run_partner_polling_job, "interval", minutes=5, id="partner_polling")
|
||||
scheduler.add_job(run_retention_job, "cron", hour=4, minute=0, id="retention")
|
||||
return scheduler
|
||||
39
gogo/main.py
Normal file
39
gogo/main.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""FastAPI application assembly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from gogo.api import actions, health, webhooks
|
||||
from gogo.config import get_settings
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s"
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
scheduler = None
|
||||
if get_settings().env != "test":
|
||||
from gogo.jobs import build_scheduler
|
||||
|
||||
scheduler = build_scheduler()
|
||||
scheduler.start()
|
||||
yield
|
||||
if scheduler:
|
||||
scheduler.shutdown(wait=False)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="Gogo Telefon", docs_url=None, redoc_url=None, lifespan=lifespan)
|
||||
app.include_router(health.router)
|
||||
app.include_router(actions.router)
|
||||
app.include_router(webhooks.router)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
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
|
||||
)
|
||||
0
gogo/proposals/__init__.py
Normal file
0
gogo/proposals/__init__.py
Normal file
217
gogo/proposals/email.py
Normal file
217
gogo/proposals/email.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""Proposal email to the owner: subject, body, action links, .ics (§9.1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gogo.config import get_settings
|
||||
from gogo.domain import Slot
|
||||
from gogo.email import EmailMessage, send_email
|
||||
from gogo.i18n import fmt_slot
|
||||
from gogo.models import BookingRequest, EmailLog, Service, Tenant
|
||||
from gogo.proposals.ics import build_ics
|
||||
from gogo.proposals.tokens import action_url, create_action_token
|
||||
|
||||
|
||||
def _slots_from_request(req: BookingRequest) -> list[Slot]:
|
||||
return [Slot.model_validate(s) for s in (req.slots or [])]
|
||||
|
||||
|
||||
async def send_proposal_email(
|
||||
session: AsyncSession, tenant: Tenant, req: BookingRequest, service: Service | None
|
||||
) -> None:
|
||||
recipients = list(tenant.notify_emails or [])
|
||||
if not recipients:
|
||||
return
|
||||
|
||||
slots = _slots_from_request(req)
|
||||
service_name = service.name if service else (req.service_name_raw or "termin")
|
||||
days = ", ".join(sorted({fmt_slot(s.start, tenant.timezone).split(",")[0] for s in slots}))
|
||||
subject = f"Novi zahtjev za termin — {service_name} — {req.client_name}"
|
||||
if days:
|
||||
subject += f" ({days})"
|
||||
|
||||
resolve_url = action_url(await create_action_token(session, req.id, "resolve"))
|
||||
reject_url = action_url(await create_action_token(session, req.id, "reject"))
|
||||
confirm_urls = [
|
||||
(
|
||||
fmt_slot(s.start, tenant.timezone),
|
||||
action_url(await create_action_token(session, req.id, "confirm", slot_index=i)),
|
||||
)
|
||||
for i, s in enumerate(slots)
|
||||
]
|
||||
|
||||
transcript_url = f"{get_settings().base_url}/t/{req.id}"
|
||||
|
||||
lines = [
|
||||
f"Novi zahtjev za termin — {tenant.name}",
|
||||
"",
|
||||
f"Klijent: {req.client_name}",
|
||||
f"Telefon: {req.client_phone}",
|
||||
f"Usluga: {service_name}",
|
||||
]
|
||||
if req.home_visit:
|
||||
lines.append(f"Dolazak na adresu: DA — {req.address or 'adresa nije navedena'}")
|
||||
if slots:
|
||||
lines.append("Traženi termini (po redoslijedu želje):")
|
||||
lines += [f" {i + 1}. {fmt_slot(s.start, tenant.timezone)}" for i, s in enumerate(slots)]
|
||||
if req.time_preference_text and not slots:
|
||||
lines.append(f"Željeno vrijeme: {req.time_preference_text}")
|
||||
if req.summary:
|
||||
lines += ["", f"Sažetak razgovora: {req.summary}"]
|
||||
lines += [
|
||||
"",
|
||||
f"Cijeli razgovor: {transcript_url}",
|
||||
"",
|
||||
"─" * 40,
|
||||
f"RIJEŠENO — kontaktirao/la sam klijenta:\n {resolve_url}",
|
||||
]
|
||||
for label, url in confirm_urls:
|
||||
lines.append(f"POTVRDI {label} + pošalji SMS klijentu:\n {url}")
|
||||
lines.append(f"ODBIJ zahtjev:\n {reject_url}")
|
||||
|
||||
html = _render_html(
|
||||
tenant, req, service_name, slots, resolve_url, confirm_urls, reject_url, transcript_url
|
||||
)
|
||||
|
||||
attachments = []
|
||||
if slots:
|
||||
first = slots[0]
|
||||
ics = build_ics(
|
||||
uid=str(req.id),
|
||||
start=first.start,
|
||||
end=first.end,
|
||||
summary=f"{service_name} — {req.client_name} (zahtjev)",
|
||||
description=(
|
||||
f"Zahtjev putem Gogo Telefona.\nKlijent: {req.client_name}, {req.client_phone}\n"
|
||||
f"{req.summary}"
|
||||
),
|
||||
)
|
||||
attachments.append(("termin.ics", "text/calendar", ics))
|
||||
|
||||
await send_email(EmailMessage(recipients, subject, "\n".join(lines), html, attachments))
|
||||
session.add(
|
||||
EmailLog(
|
||||
tenant_id=tenant.id,
|
||||
to_addr=", ".join(recipients),
|
||||
subject=subject,
|
||||
kind="proposal",
|
||||
status="sent",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _render_html(
|
||||
tenant: Tenant,
|
||||
req: BookingRequest,
|
||||
service_name: str,
|
||||
slots: list[Slot],
|
||||
resolve_url: str,
|
||||
confirm_urls: list[tuple[str, str]],
|
||||
reject_url: str,
|
||||
transcript_url: str,
|
||||
) -> str:
|
||||
def esc(s: str) -> str:
|
||||
return (
|
||||
str(s).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
)
|
||||
|
||||
slot_rows = "".join(
|
||||
f"<li>{esc(fmt_slot(s.start, tenant.timezone))}</li>" for s in slots
|
||||
)
|
||||
confirm_buttons = "".join(
|
||||
f'<p><a href="{url}" style="background:#2563eb;color:#fff;padding:10px 16px;'
|
||||
f'border-radius:6px;text-decoration:none;display:inline-block">'
|
||||
f"Potvrdi {esc(label)} + SMS</a></p>"
|
||||
for label, url in confirm_urls
|
||||
)
|
||||
home = (
|
||||
f"<p><b>Dolazak na adresu:</b> DA — {esc(req.address or 'adresa nije navedena')}</p>"
|
||||
if req.home_visit
|
||||
else ""
|
||||
)
|
||||
pref = (
|
||||
f"<p><b>Željeno vrijeme:</b> {esc(req.time_preference_text)}</p>"
|
||||
if req.time_preference_text and not slots
|
||||
else ""
|
||||
)
|
||||
summary = f"<p><b>Sažetak:</b> {esc(req.summary)}</p>" if req.summary else ""
|
||||
return f"""
|
||||
<div style="font-family:sans-serif;max-width:560px">
|
||||
<h2 style="margin-bottom:4px">Novi zahtjev za termin</h2>
|
||||
<p style="color:#666;margin-top:0">{esc(tenant.name)}</p>
|
||||
<p><b>Klijent:</b> {esc(req.client_name)}<br>
|
||||
<b>Telefon:</b> {esc(req.client_phone)}<br>
|
||||
<b>Usluga:</b> {esc(service_name)}</p>
|
||||
{home}
|
||||
{"<p><b>Traženi termini:</b></p><ol>" + slot_rows + "</ol>" if slots else ""}
|
||||
{pref}
|
||||
{summary}
|
||||
<p><a href="{transcript_url}">Cijeli razgovor →</a></p>
|
||||
<hr>
|
||||
<p><a href="{resolve_url}" style="background:#16a34a;color:#fff;padding:12px 20px;
|
||||
border-radius:6px;text-decoration:none;display:inline-block;font-weight:bold">
|
||||
✓ Riješeno — kontaktirao/la sam klijenta</a></p>
|
||||
{confirm_buttons}
|
||||
<p><a href="{reject_url}" style="color:#dc2626">Odbij zahtjev</a></p>
|
||||
<p style="color:#999;font-size:12px">Gogo Telefon — virtuelni asistent salona.
|
||||
U prilogu je .ics za prvi traženi termin (možete ga pomjeriti prije spremanja u kalendar).</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
async def send_reminder_email(session: AsyncSession, tenant: Tenant, req: BookingRequest) -> None:
|
||||
"""Reminder at 50% of proposal TTL (§9.2)."""
|
||||
recipients = list(tenant.notify_emails or [])
|
||||
if not recipients:
|
||||
return
|
||||
subject = f"Podsjetnik: neodgovoren zahtjev — {req.client_name}"
|
||||
ttl = tenant.proposal_ttl_hours or 24
|
||||
body = (
|
||||
f"Zahtjev klijenta {req.client_name} ({req.client_phone}) čeka odgovor.\n"
|
||||
f"Ako ne odgovorite u roku od {ttl // 2}h, zahtjev ističe i klijent dobija "
|
||||
f"SMS s molbom da nazove ponovo.\n\n"
|
||||
f"Pregled: {get_settings().base_url}/t/{req.id}"
|
||||
)
|
||||
await send_email(EmailMessage(recipients, subject, body))
|
||||
session.add(
|
||||
EmailLog(
|
||||
tenant_id=tenant.id,
|
||||
to_addr=", ".join(recipients),
|
||||
subject=subject,
|
||||
kind="proposal_reminder",
|
||||
status="sent",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def send_usage_warning_email(
|
||||
session: AsyncSession, tenant: Tenant, used_minutes: int, pct: int
|
||||
) -> None:
|
||||
recipients = list(tenant.notify_emails or [])
|
||||
if not recipients:
|
||||
return
|
||||
subject = f"Gogo Telefon: iskorišteno {pct}% minuta ovaj mjesec"
|
||||
body = (
|
||||
f"Salon {tenant.name} je iskoristio {used_minutes} od {tenant.included_minutes} "
|
||||
f"uključenih minuta virtualnog asistenta ovaj mjesec.\n"
|
||||
"Asistent nastavlja odgovarati na pozive. Za veći paket javite se Gogo podršci."
|
||||
)
|
||||
await send_email(EmailMessage(recipients, subject, body))
|
||||
session.add(
|
||||
EmailLog(
|
||||
tenant_id=tenant.id,
|
||||
to_addr=", ".join(recipients),
|
||||
subject=subject,
|
||||
kind="usage_warning",
|
||||
status="sent",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def now_utc() -> datetime:
|
||||
from datetime import UTC
|
||||
|
||||
return datetime.now(UTC)
|
||||
251
gogo/proposals/engine.py
Normal file
251
gogo/proposals/engine.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""Proposal engine (§9): create booking requests, route delivery through the
|
||||
tenant's scheduling provider, drive state transitions and client SMS.
|
||||
|
||||
States: pending → resolved_by_owner | confirmed | rejected | expired
|
||||
The same transition functions are used by email action links, the dashboard,
|
||||
and the partner webhook — semantics are identical everywhere (§B.3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gogo.config import get_settings
|
||||
from gogo.domain import BookingRequestData, BookingStatus, DeliveryResult, Slot
|
||||
from gogo.i18n import fmt_date, fmt_slot, fmt_time
|
||||
from gogo.models import BookingRequest, Service, Tenant, utcnow
|
||||
from gogo.scheduling.base import get_provider
|
||||
from gogo.sms import send_sms
|
||||
from gogo.sms.templates import render_sms
|
||||
|
||||
log = logging.getLogger("gogo.proposals")
|
||||
|
||||
|
||||
class TransitionError(Exception):
|
||||
"""Invalid state transition (e.g. confirming an already-rejected request)."""
|
||||
|
||||
|
||||
async def create_booking_request(
|
||||
session: AsyncSession,
|
||||
tenant: Tenant,
|
||||
*,
|
||||
source: str,
|
||||
client_name: str,
|
||||
client_phone: str,
|
||||
service_id: str | None = None,
|
||||
service_name_raw: str = "",
|
||||
slots: list[Slot] | None = None,
|
||||
time_preference_text: str = "",
|
||||
home_visit: bool = False,
|
||||
address: str | None = None,
|
||||
summary: str = "",
|
||||
call_id: uuid.UUID | None = None,
|
||||
chat_session_id: uuid.UUID | None = None,
|
||||
) -> tuple[BookingRequest, DeliveryResult]:
|
||||
"""Create a pending request and deliver it via the tenant's provider."""
|
||||
slots = (slots or [])[:3] # hard cap: at most 3 offered slots (§6.2)
|
||||
now = utcnow()
|
||||
req = BookingRequest(
|
||||
tenant_id=tenant.id,
|
||||
source=source,
|
||||
client_name=client_name.strip(),
|
||||
client_phone=client_phone.strip(),
|
||||
service_id=uuid.UUID(service_id) if service_id else None,
|
||||
service_name_raw=service_name_raw,
|
||||
slots=[s.model_dump(mode="json") for s in slots],
|
||||
time_preference_text=time_preference_text,
|
||||
home_visit=home_visit,
|
||||
address=address,
|
||||
summary=summary,
|
||||
call_id=call_id,
|
||||
chat_session_id=chat_session_id,
|
||||
status=BookingStatus.pending.value,
|
||||
expires_at=now + timedelta(hours=tenant.proposal_ttl_hours or 24),
|
||||
)
|
||||
session.add(req)
|
||||
await session.flush() # assign req.id
|
||||
|
||||
data = BookingRequestData(
|
||||
gogo_request_id=str(req.id),
|
||||
tenant_id=str(tenant.id),
|
||||
created_at=now,
|
||||
source=source,
|
||||
client_name=req.client_name,
|
||||
client_phone=req.client_phone,
|
||||
service_id=service_id,
|
||||
service_name_raw=service_name_raw,
|
||||
requested_slots=slots,
|
||||
time_preference_text=time_preference_text,
|
||||
home_visit=home_visit,
|
||||
address=address,
|
||||
summary=summary,
|
||||
transcript_url=f"{get_settings().base_url}/t/{req.id}",
|
||||
)
|
||||
provider = await get_provider(session, tenant)
|
||||
result = await provider.deliver_request(data)
|
||||
if not result.ok:
|
||||
log.error("delivery failed for request %s: %s", req.id, result.detail)
|
||||
|
||||
# Optional "request received" SMS to the client (§9.1)
|
||||
if tenant.sms_request_received and req.client_phone:
|
||||
await send_sms(
|
||||
session,
|
||||
tenant.id,
|
||||
req.client_phone,
|
||||
render_sms(tenant, "request_received"),
|
||||
kind="received",
|
||||
)
|
||||
return req, result
|
||||
|
||||
|
||||
# -- transitions -------------------------------------------------------------
|
||||
|
||||
|
||||
async def resolve_request(
|
||||
session: AsyncSession, tenant: Tenant, req: BookingRequest, note: str = ""
|
||||
) -> None:
|
||||
"""Owner contacted the client directly — primary flow. Gogo sends NO SMS (§9.2)."""
|
||||
_require_pending(req, allow_same=BookingStatus.resolved_by_owner)
|
||||
if req.status != BookingStatus.pending.value:
|
||||
return # idempotent replay
|
||||
req.status = BookingStatus.resolved_by_owner.value
|
||||
req.resolved_at = utcnow()
|
||||
req.resolution_note = note
|
||||
|
||||
|
||||
async def confirm_request(
|
||||
session: AsyncSession,
|
||||
tenant: Tenant,
|
||||
req: BookingRequest,
|
||||
slot: Slot,
|
||||
*,
|
||||
recheck: bool = True,
|
||||
) -> bool:
|
||||
"""Owner confirms a slot → confirmation SMS to client.
|
||||
|
||||
Returns False (no transition) if recheck finds the slot busy — the caller
|
||||
should show a warning and let the owner decide (force with recheck=False).
|
||||
"""
|
||||
_require_pending(req, allow_same=BookingStatus.confirmed)
|
||||
if req.status == BookingStatus.confirmed.value:
|
||||
return True # idempotent replay
|
||||
|
||||
if recheck:
|
||||
provider = await get_provider(session, tenant)
|
||||
checker = getattr(provider, "is_slot_free", None)
|
||||
if checker is not None:
|
||||
try:
|
||||
free = await checker(
|
||||
str(req.service_id) if req.service_id else None, slot
|
||||
)
|
||||
except Exception: # noqa: BLE001 — recheck is best-effort
|
||||
log.exception("free/busy recheck failed for %s", req.id)
|
||||
free = True
|
||||
if not free:
|
||||
return False
|
||||
|
||||
req.status = BookingStatus.confirmed.value
|
||||
req.confirmed_slot = slot.model_dump(mode="json")
|
||||
req.resolved_at = utcnow()
|
||||
|
||||
service_name = await _service_name(session, req)
|
||||
day_name = fmt_slot(slot.start, tenant.timezone).split(",")[0]
|
||||
body = render_sms(
|
||||
tenant,
|
||||
"confirmation",
|
||||
usluga=service_name,
|
||||
dan=day_name,
|
||||
datum=fmt_date(slot.start, tenant.timezone),
|
||||
vrijeme=fmt_time(slot.start, tenant.timezone),
|
||||
)
|
||||
if req.client_phone:
|
||||
await send_sms(session, tenant.id, req.client_phone, body, kind="confirmation")
|
||||
return True
|
||||
|
||||
|
||||
async def reject_request(
|
||||
session: AsyncSession, tenant: Tenant, req: BookingRequest, custom_sms: str | None = None
|
||||
) -> None:
|
||||
"""Reject → rejection SMS to client (template, editable before send §9.2)."""
|
||||
_require_pending(req, allow_same=BookingStatus.rejected)
|
||||
if req.status == BookingStatus.rejected.value:
|
||||
return # idempotent replay
|
||||
req.status = BookingStatus.rejected.value
|
||||
req.resolved_at = utcnow()
|
||||
body = custom_sms or render_sms(tenant, "rejection")
|
||||
if req.client_phone:
|
||||
await send_sms(session, tenant.id, req.client_phone, body, kind="rejection")
|
||||
|
||||
|
||||
async def expire_request(session: AsyncSession, tenant: Tenant, req: BookingRequest) -> None:
|
||||
"""TTL passed with no owner action → apology SMS to client (§9.2)."""
|
||||
if req.status != BookingStatus.pending.value:
|
||||
return
|
||||
req.status = BookingStatus.expired.value
|
||||
req.resolved_at = utcnow()
|
||||
if req.client_phone:
|
||||
await send_sms(
|
||||
session, tenant.id, req.client_phone, render_sms(tenant, "expiry"), kind="expiry"
|
||||
)
|
||||
|
||||
|
||||
def _require_pending(req: BookingRequest, allow_same: BookingStatus) -> None:
|
||||
if req.status not in (BookingStatus.pending.value, allow_same.value):
|
||||
raise TransitionError(
|
||||
f"request {req.id} is {req.status}, cannot transition to {allow_same.value}"
|
||||
)
|
||||
|
||||
|
||||
async def _service_name(session: AsyncSession, req: BookingRequest) -> str:
|
||||
if req.service_id:
|
||||
service = (
|
||||
await session.execute(select(Service).where(Service.id == req.service_id))
|
||||
).scalar_one_or_none()
|
||||
if service:
|
||||
return service.name
|
||||
return req.service_name_raw or "termin"
|
||||
|
||||
|
||||
# -- background jobs ---------------------------------------------------------
|
||||
|
||||
|
||||
async def process_expirations(session: AsyncSession) -> int:
|
||||
"""Expire overdue pending requests; send owner reminders at 50% TTL. Returns count expired."""
|
||||
from gogo.proposals.email import send_reminder_email
|
||||
|
||||
now = datetime.now(UTC)
|
||||
pending = (
|
||||
(
|
||||
await session.execute(
|
||||
select(BookingRequest).where(
|
||||
BookingRequest.status == BookingStatus.pending.value
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
expired = 0
|
||||
for req in pending:
|
||||
tenant = (
|
||||
await session.execute(select(Tenant).where(Tenant.id == req.tenant_id))
|
||||
).scalar_one()
|
||||
expires_at = req.expires_at
|
||||
if expires_at is None:
|
||||
continue
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
if expires_at <= now:
|
||||
await expire_request(session, tenant, req)
|
||||
expired += 1
|
||||
else:
|
||||
ttl = timedelta(hours=tenant.proposal_ttl_hours or 24)
|
||||
if not req.reminder_sent and expires_at - now <= ttl / 2:
|
||||
await send_reminder_email(session, tenant, req)
|
||||
req.reminder_sent = True
|
||||
return expired
|
||||
42
gogo/proposals/ics.py
Normal file
42
gogo/proposals/ics.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Generate the .ics attachment (METHOD:REQUEST) for the first-choice slot (§9.1).
|
||||
|
||||
The .ics is a convenience so Gmail offers "Add to calendar" — the owner can move
|
||||
and rearrange the event before saving. Gogo never writes to the owner's calendar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from icalendar import Calendar, Event, vCalAddress, vText
|
||||
|
||||
from gogo.config import get_settings
|
||||
|
||||
|
||||
def build_ics(
|
||||
*,
|
||||
uid: str,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
summary: str,
|
||||
description: str,
|
||||
organizer_email: str = "noreply@gogotelefon.ba",
|
||||
) -> bytes:
|
||||
cal = Calendar()
|
||||
cal.add("prodid", "-//Gogo Telefon//gogotelefon.ba//BS")
|
||||
cal.add("version", "2.0")
|
||||
cal.add("method", "REQUEST")
|
||||
|
||||
ev = Event()
|
||||
ev.add("uid", f"{uid}@gogotelefon.ba")
|
||||
ev.add("dtstart", start)
|
||||
ev.add("dtend", end)
|
||||
ev.add("summary", summary)
|
||||
ev.add("description", description)
|
||||
ev.add("status", "TENTATIVE")
|
||||
organizer = vCalAddress(f"MAILTO:{organizer_email}")
|
||||
organizer.params["cn"] = vText("Gogo Telefon")
|
||||
ev["organizer"] = organizer
|
||||
ev.add("url", get_settings().base_url)
|
||||
cal.add_component(ev)
|
||||
return cal.to_ical()
|
||||
44
gogo/proposals/tokens.py
Normal file
44
gogo/proposals/tokens.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Single-use signed action tokens for proposal email links (§9.1).
|
||||
|
||||
Tokens are random, stored in DB (single-use enforced there) and the URL carries
|
||||
an itsdangerous signature so guessing/forging is infeasible even if the DB row
|
||||
leaked. Action links must be idempotent (§15): a used 'resolve' link re-visited
|
||||
shows "already resolved", it never errors or double-fires.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
from itsdangerous import BadSignature, URLSafeSerializer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gogo.config import get_settings
|
||||
from gogo.models import ActionToken
|
||||
|
||||
|
||||
def _serializer() -> URLSafeSerializer:
|
||||
return URLSafeSerializer(get_settings().secret_key, salt="proposal-action")
|
||||
|
||||
|
||||
async def create_action_token(
|
||||
session: AsyncSession, request_id: uuid.UUID, action: str, slot_index: int | None = None
|
||||
) -> str:
|
||||
"""Create a DB-backed token and return the signed URL-safe token string."""
|
||||
raw = secrets.token_urlsafe(24)[:40]
|
||||
session.add(
|
||||
ActionToken(token=raw, request_id=request_id, action=action, slot_index=slot_index)
|
||||
)
|
||||
return _serializer().dumps(raw)
|
||||
|
||||
|
||||
def action_url(signed: str) -> str:
|
||||
return f"{get_settings().base_url}/a/{signed}"
|
||||
|
||||
|
||||
def unsign(signed: str) -> str | None:
|
||||
try:
|
||||
return _serializer().loads(signed)
|
||||
except BadSignature:
|
||||
return None
|
||||
1
gogo/scheduling/__init__.py
Normal file
1
gogo/scheduling/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from gogo.scheduling.base import SchedulingProvider, get_provider # noqa: F401
|
||||
72
gogo/scheduling/base.py
Normal file
72
gogo/scheduling/base.py
Normal 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 {}
|
||||
211
gogo/scheduling/google_calendar.py
Normal file
211
gogo/scheduling/google_calendar.py
Normal 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
81
gogo/scheduling/mock.py
Normal 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)")
|
||||
226
gogo/scheduling/partner_api.py
Normal file
226
gogo/scheduling/partner_api.py
Normal 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
77
gogo/scheduling/slots.py
Normal 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
|
||||
1
gogo/sms/__init__.py
Normal file
1
gogo/sms/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from gogo.sms.base import SmsProvider, get_sms_provider, send_sms # noqa: F401
|
||||
72
gogo/sms/base.py
Normal file
72
gogo/sms/base.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""SmsProvider interface. Console impl for dev/tests; GSM gateway impl in gateway.py (M5)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Protocol
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gogo.config import get_settings
|
||||
from gogo.models import SmsLog
|
||||
|
||||
log = logging.getLogger("gogo.sms")
|
||||
|
||||
|
||||
class SmsProvider(Protocol):
|
||||
async def send(self, tenant_id: uuid.UUID, to_msisdn: str, body: str) -> None:
|
||||
"""Send one SMS from the tenant's assigned SIM. Raises on failure."""
|
||||
...
|
||||
|
||||
|
||||
class ConsoleSmsProvider:
|
||||
"""Logs SMS instead of sending; keeps them in memory for tests."""
|
||||
|
||||
sent: list[tuple[str, str]]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent = []
|
||||
|
||||
async def send(self, tenant_id: uuid.UUID, to_msisdn: str, body: str) -> None:
|
||||
self.sent.append((to_msisdn, body))
|
||||
log.info("SMS tenant=%s to=%s: %s", tenant_id, to_msisdn, body)
|
||||
|
||||
|
||||
_provider: SmsProvider | None = None
|
||||
|
||||
|
||||
def get_sms_provider() -> SmsProvider:
|
||||
global _provider
|
||||
if _provider is None:
|
||||
if get_settings().sms_provider == "gsm_gateway":
|
||||
from gogo.sms.gateway import GsmGatewaySmsProvider
|
||||
|
||||
_provider = GsmGatewaySmsProvider()
|
||||
else:
|
||||
_provider = ConsoleSmsProvider()
|
||||
return _provider
|
||||
|
||||
|
||||
def set_sms_provider(p: SmsProvider | None) -> None:
|
||||
"""Test hook."""
|
||||
global _provider
|
||||
_provider = p
|
||||
|
||||
|
||||
async def send_sms(
|
||||
session: AsyncSession, tenant_id: uuid.UUID, to_msisdn: str, body: str, kind: str
|
||||
) -> bool:
|
||||
"""Send + log. Returns success. Never raises (SMS failure must not break the flow)."""
|
||||
row = SmsLog(tenant_id=tenant_id, to_msisdn=to_msisdn, body=body, kind=kind)
|
||||
try:
|
||||
await get_sms_provider().send(tenant_id, to_msisdn, body)
|
||||
row.status = "sent"
|
||||
ok = True
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.exception("SMS send failed tenant=%s to=%s", tenant_id, to_msisdn)
|
||||
row.status = "failed"
|
||||
row.error = str(e)
|
||||
ok = False
|
||||
session.add(row)
|
||||
return ok
|
||||
37
gogo/sms/templates.py
Normal file
37
gogo/sms/templates.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Per-tenant SMS templates with safe Bosnian defaults (§5.5).
|
||||
|
||||
Tenant overrides live in tenants.sms_templates (only overridden keys stored).
|
||||
Placeholders are .format()-style; unknown placeholders are left intact.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from gogo.models import Tenant
|
||||
|
||||
DEFAULT_TEMPLATES: dict[str, str] = {
|
||||
"missed_call": (
|
||||
"Poštovani, dobili ste {salon}. Možete zakazati i putem poruke ili chata: {link}. "
|
||||
"Nazvaćemo vas ili nas pozovite ponovo."
|
||||
),
|
||||
"request_received": (
|
||||
"Primili smo vaš zahtjev za termin. Javićemo vam potvrdu u najkraćem roku. — {salon}"
|
||||
),
|
||||
"confirmation": "Potvrđen termin: {usluga}, {dan} {datum} u {vrijeme}h — {salon}.",
|
||||
"rejection": "{salon}: nažalost traženi termin nije moguć. Molimo pozovite nas da dogovorimo drugi.",
|
||||
"expiry": (
|
||||
"{salon}: nismo uspjeli potvrditi vaš zahtjev za termin. "
|
||||
"Molimo pozovite nas ponovo da dogovorimo termin."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class _SafeDict(dict):
|
||||
def __missing__(self, key: str) -> str:
|
||||
return "{" + key + "}"
|
||||
|
||||
|
||||
def render_sms(tenant: Tenant, kind: str, **params: str) -> str:
|
||||
templates = {**DEFAULT_TEMPLATES, **(tenant.sms_templates or {})}
|
||||
template = templates[kind]
|
||||
params.setdefault("salon", tenant.name)
|
||||
return template.format_map(_SafeDict(params))
|
||||
61
pyproject.toml
Normal file
61
pyproject.toml
Normal file
@@ -0,0 +1,61 @@
|
||||
[project]
|
||||
name = "gogotelefon"
|
||||
version = "0.1.0"
|
||||
description = "Gogo Telefon — voice-first AI receptionist for small service businesses in BiH"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"sqlalchemy>=2.0",
|
||||
"alembic>=1.13",
|
||||
"psycopg[binary]>=3.2",
|
||||
"pydantic>=2.8",
|
||||
"pydantic-settings>=2.4",
|
||||
"jinja2>=3.1",
|
||||
"python-multipart>=0.0.9",
|
||||
"itsdangerous>=2.2",
|
||||
"cryptography>=43.0",
|
||||
"bcrypt>=4.2",
|
||||
"httpx>=0.27",
|
||||
"aiosmtplib>=3.0",
|
||||
"icalendar>=5.0",
|
||||
"apscheduler>=3.10",
|
||||
"anthropic>=0.40",
|
||||
"google-auth>=2.34",
|
||||
"google-auth-oauthlib>=1.2",
|
||||
"qrcode[pil]>=7.4",
|
||||
"websockets>=13.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pytest-httpx>=0.32",
|
||||
"ruff>=0.6",
|
||||
"aiosqlite>=0.20",
|
||||
"greenlet>=3.0",
|
||||
]
|
||||
voice = [
|
||||
"pipecat-ai>=0.0.40",
|
||||
"faster-whisper>=1.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["gogo"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B"]
|
||||
ignore = ["E501", "B008"] # B008: Depends(...) in defaults is FastAPI idiom
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
165
tests/conftest.py
Normal file
165
tests/conftest.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Shared fixtures: file-backed SQLite DB per test, console email/SMS capture,
|
||||
seeded tenant, fake partner wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
os.environ.setdefault("GOGO_ENV", "test")
|
||||
os.environ.setdefault("GOGO_SECRET_KEY", "test-secret-key-for-tests-only")
|
||||
os.environ.setdefault("GOGO_BASE_URL", "http://testserver")
|
||||
|
||||
import httpx # noqa: E402
|
||||
import pytest # noqa: E402
|
||||
|
||||
import gogo.db as db # noqa: E402
|
||||
from gogo.config import get_settings # noqa: E402
|
||||
from gogo.crypto import encrypt # noqa: E402
|
||||
from gogo.email.sender import ConsoleEmailProvider, set_email_provider # noqa: E402
|
||||
from gogo.hours import DEFAULT_WORKING_HOURS # noqa: E402
|
||||
from gogo.models import ProviderConfig, Service, Tenant # noqa: E402
|
||||
from gogo.scheduling.mock import MockProvider # noqa: E402
|
||||
from gogo.scheduling.partner_api import PartnerApiProvider # noqa: E402
|
||||
from gogo.sms.base import ConsoleSmsProvider, set_sms_provider # noqa: E402
|
||||
from tests import fake_partner # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session(tmp_path):
|
||||
"""Fresh file-backed SQLite DB; the app's get_session sees the same file."""
|
||||
db_path = tmp_path / "test.db"
|
||||
os.environ["GOGO_DATABASE_URL"] = f"sqlite+aiosqlite:///{db_path}"
|
||||
get_settings.cache_clear()
|
||||
db.reset_engine()
|
||||
engine = db.get_engine()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(db.Base.metadata.create_all)
|
||||
async with db.get_sessionmaker()() as s:
|
||||
yield s
|
||||
await engine.dispose()
|
||||
db.reset_engine()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def emails():
|
||||
provider = ConsoleEmailProvider()
|
||||
set_email_provider(provider)
|
||||
yield provider.sent
|
||||
set_email_provider(None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sms():
|
||||
provider = ConsoleSmsProvider()
|
||||
set_sms_provider(provider)
|
||||
yield provider.sent
|
||||
set_sms_provider(None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_mock_provider():
|
||||
MockProvider.busy_blocks = {}
|
||||
MockProvider.now_override = None
|
||||
yield MockProvider
|
||||
MockProvider.busy_blocks = {}
|
||||
MockProvider.now_override = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def tenant(session) -> Tenant:
|
||||
"""Seeded mock-provider tenant 'Salon Merima' with two services."""
|
||||
t = Tenant(
|
||||
name="Salon Merima",
|
||||
slug="salon-merima",
|
||||
city="Banja Luka",
|
||||
working_hours=DEFAULT_WORKING_HOURS,
|
||||
scheduling_provider="mock",
|
||||
notify_emails=["merima@example.ba"],
|
||||
)
|
||||
session.add(t)
|
||||
await session.flush()
|
||||
session.add_all(
|
||||
[
|
||||
Service(
|
||||
tenant_id=t.id, name="Šišanje i feniranje", duration_min=45,
|
||||
price_min=25, price_max=35,
|
||||
),
|
||||
Service(
|
||||
tenant_id=t.id, name="Pedikir", duration_min=60,
|
||||
price_min=30, price_max=30, home_visit=True,
|
||||
),
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
return t
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def partner_tenant(session) -> Tenant:
|
||||
"""Tenant wired to the fake partner API via ASGI transport."""
|
||||
t = Tenant(
|
||||
name="Salon Aida",
|
||||
slug="salon-aida",
|
||||
city="Sarajevo",
|
||||
working_hours=DEFAULT_WORKING_HOURS,
|
||||
scheduling_provider="partner_api",
|
||||
notify_emails=[],
|
||||
)
|
||||
session.add(t)
|
||||
await session.flush()
|
||||
session.add(
|
||||
ProviderConfig(
|
||||
tenant_id=t.id,
|
||||
provider_type="partner_api",
|
||||
config={
|
||||
"base_url": "http://partner.test",
|
||||
"api_key_encrypted": encrypt(fake_partner.API_KEY),
|
||||
"webhook_secret_encrypted": encrypt(fake_partner.WEBHOOK_SECRET),
|
||||
"catalog_sync": True,
|
||||
"email_to_owner": False,
|
||||
"polling_fallback": False,
|
||||
},
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
Service(
|
||||
tenant_id=t.id, name="Manikir", duration_min=45,
|
||||
price_min=20, price_max=25, partner_service_id="SRV-12",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return t
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wire_fake_partner(monkeypatch):
|
||||
"""Point PartnerApiProvider's HTTP client at the fake partner ASGI app."""
|
||||
fake_partner.state.reset()
|
||||
|
||||
def factory(timeout):
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=fake_partner.app),
|
||||
base_url="http://partner.test",
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(PartnerApiProvider, "client_factory", staticmethod(factory))
|
||||
yield fake_partner.state
|
||||
fake_partner.state.reset()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def gogo_client(session):
|
||||
"""HTTP client against the Gogo app (shares the test DB via GOGO_DATABASE_URL)."""
|
||||
from gogo.main import create_app
|
||||
|
||||
app = create_app()
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
|
||||
def make_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
176
tests/fake_partner.py
Normal file
176
tests/fake_partner.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Fake partner booking-software API — reference implementation of Appendix B.
|
||||
|
||||
Used by the test suite to exercise the `partner_api` provider (auth, idempotency
|
||||
replay, webhook signatures, polling fallback) AND intended to be handed to
|
||||
partner IT teams as a working example of the contract they must implement.
|
||||
|
||||
Run standalone: uvicorn tests.fake_partner:app --port 9100
|
||||
(API key: "test-partner-key"; see PartnerState to preload slots.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Header, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
API_KEY = "test-partner-key"
|
||||
WEBHOOK_SECRET = "test-webhook-secret"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PartnerState:
|
||||
"""In-memory 'database' of the partner's booking software."""
|
||||
|
||||
# service_id -> list of slot dicts {"start", "end", optionally staff}
|
||||
slots: dict[str, list[dict]] = field(default_factory=dict)
|
||||
# idempotency key -> stored response
|
||||
idempotency: dict[str, dict] = field(default_factory=dict)
|
||||
# partner_request_id -> request record (incl. "status")
|
||||
requests: dict[str, dict] = field(default_factory=dict)
|
||||
services: list[dict] = field(default_factory=list)
|
||||
seq: int = 0
|
||||
# availability latency injection for tests (seconds)
|
||||
availability_delay: float = 0.0
|
||||
|
||||
def reset(self) -> None:
|
||||
self.slots.clear()
|
||||
self.idempotency.clear()
|
||||
self.requests.clear()
|
||||
self.services.clear()
|
||||
self.seq = 0
|
||||
self.availability_delay = 0.0
|
||||
|
||||
|
||||
state = PartnerState()
|
||||
app = FastAPI(title="Fake Partner Booking API (Appendix B reference)")
|
||||
|
||||
|
||||
def _err(status: int, code: str, message: str) -> JSONResponse:
|
||||
# §B.0 error shape
|
||||
return JSONResponse({"error": {"code": code, "message": message}}, status_code=status)
|
||||
|
||||
|
||||
def _check_auth(authorization: str | None) -> JSONResponse | None:
|
||||
if authorization != f"Bearer {API_KEY}":
|
||||
return _err(401, "unauthorized", "Missing or invalid API key")
|
||||
return None
|
||||
|
||||
|
||||
@app.get("/availability")
|
||||
async def availability(
|
||||
service_id: str = Query(...),
|
||||
home_visit: str = Query("false"),
|
||||
from_: str = Query(..., alias="from"),
|
||||
to: str = Query(...),
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
"""§B.1 — ready-to-offer free slots; partner does all the slot math."""
|
||||
if err := _check_auth(authorization):
|
||||
return err
|
||||
if state.availability_delay:
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(state.availability_delay)
|
||||
date_from = datetime.fromisoformat(from_).date() if len(from_) > 10 else from_
|
||||
date_to = datetime.fromisoformat(to).date() if len(to) > 10 else to
|
||||
slots = [
|
||||
s
|
||||
for s in state.slots.get(service_id, [])
|
||||
if str(date_from) <= s["start"][:10] <= str(date_to)
|
||||
]
|
||||
return {"service_id": service_id, "slots": slots[:20]}
|
||||
|
||||
|
||||
@app.post("/booking-requests", status_code=201)
|
||||
async def create_booking_request(
|
||||
request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
idempotency_key: str | None = Header(default=None),
|
||||
):
|
||||
"""§B.2 — push a booking request; idempotent per Idempotency-Key."""
|
||||
if err := _check_auth(authorization):
|
||||
return err
|
||||
payload = await request.json()
|
||||
|
||||
key = idempotency_key or payload.get("gogo_request_id", "")
|
||||
if key in state.idempotency:
|
||||
return state.idempotency[key] # replay MUST return the original result
|
||||
|
||||
if payload.get("dry_run"):
|
||||
return {"partner_request_id": None, "status": "dry_run_ok"}
|
||||
|
||||
state.seq += 1
|
||||
partner_id = f"REQ-{state.seq:04d}"
|
||||
state.requests[partner_id] = {**payload, "status": "pending"}
|
||||
response = {"partner_request_id": partner_id, "status": "received"}
|
||||
state.idempotency[key] = response
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/booking-requests/{partner_request_id}")
|
||||
async def booking_request_status(
|
||||
partner_request_id: str, authorization: str | None = Header(default=None)
|
||||
):
|
||||
"""§B.3 polling fallback."""
|
||||
if err := _check_auth(authorization):
|
||||
return err
|
||||
rec = state.requests.get(partner_request_id)
|
||||
if rec is None:
|
||||
return _err(404, "not_found", "Unknown partner_request_id")
|
||||
return {"status": rec["status"], "confirmed_slot": rec.get("confirmed_slot")}
|
||||
|
||||
|
||||
@app.get("/services")
|
||||
async def services(authorization: str | None = Header(default=None)):
|
||||
"""§B.4 optional catalog endpoint."""
|
||||
if err := _check_auth(authorization):
|
||||
return err
|
||||
return {"services": state.services}
|
||||
|
||||
|
||||
# -- helpers for driving the partner side in tests / demos -------------------
|
||||
|
||||
|
||||
def sign_webhook(body: bytes, secret: str = WEBHOOK_SECRET) -> str:
|
||||
return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
async def staff_resolve(
|
||||
gogo_base: httpx.AsyncClient,
|
||||
tenant_id: str,
|
||||
partner_request_id: str,
|
||||
outcome: str,
|
||||
confirmed_slot: dict | None = None,
|
||||
secret: str = WEBHOOK_SECRET,
|
||||
) -> httpx.Response:
|
||||
"""Simulate salon staff resolving a request in the partner software:
|
||||
updates local state and calls Gogo's outcome webhook (§B.3)."""
|
||||
import json as jsonlib
|
||||
|
||||
rec = state.requests.get(partner_request_id)
|
||||
if rec is not None:
|
||||
rec["status"] = "resolved" if outcome == "resolved" else outcome
|
||||
if confirmed_slot:
|
||||
rec["confirmed_slot"] = confirmed_slot
|
||||
payload = {
|
||||
"gogo_request_id": rec["gogo_request_id"] if rec else None,
|
||||
"partner_request_id": partner_request_id,
|
||||
"outcome": outcome,
|
||||
}
|
||||
if confirmed_slot:
|
||||
payload["confirmed_slot"] = confirmed_slot
|
||||
body = jsonlib.dumps(payload).encode()
|
||||
return await gogo_base.post(
|
||||
f"/webhooks/partner/{tenant_id}",
|
||||
content=body,
|
||||
headers={
|
||||
"X-Gogo-Signature": sign_webhook(body, secret),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
133
tests/test_action_links.py
Normal file
133
tests/test_action_links.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Owner email action links over HTTP (§9.1-9.2, idempotency per §15)."""
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from gogo.domain import BookingStatus
|
||||
from tests.test_proposal_lifecycle import create
|
||||
|
||||
|
||||
def extract_links(text: str) -> dict[str, str]:
|
||||
"""Pull action URLs out of the plain-text proposal email."""
|
||||
links = {}
|
||||
resolve = re.search(r"RIJEŠENO[^\n]*\n\s+(http\S+)", text)
|
||||
confirm = re.search(r"POTVRDI[^\n]*\n\s+(http\S+)", text)
|
||||
reject = re.search(r"ODBIJ[^\n]*\n\s+(http\S+)", text)
|
||||
if resolve:
|
||||
links["resolve"] = resolve.group(1)
|
||||
if confirm:
|
||||
links["confirm"] = confirm.group(1)
|
||||
if reject:
|
||||
links["reject"] = reject.group(1)
|
||||
return links
|
||||
|
||||
|
||||
async def test_resolve_link_closes_request_no_sms(session, tenant, emails, sms, gogo_client):
|
||||
req, _ = await create(session, tenant)
|
||||
await session.commit()
|
||||
links = extract_links(emails[0].text)
|
||||
|
||||
resp = await gogo_client.get(links["resolve"])
|
||||
assert resp.status_code == 200
|
||||
assert "Označeno kao riješeno" in resp.text
|
||||
|
||||
await session.refresh(req)
|
||||
assert req.status == BookingStatus.resolved_by_owner.value
|
||||
assert sms == []
|
||||
|
||||
# idempotent: second click shows friendly "already handled" page
|
||||
resp2 = await gogo_client.get(links["resolve"])
|
||||
assert resp2.status_code == 200
|
||||
assert "već obrađen" in resp2.text
|
||||
|
||||
|
||||
async def test_confirm_link_sends_sms(session, tenant, emails, sms, gogo_client):
|
||||
req, _ = await create(session, tenant)
|
||||
await session.commit()
|
||||
links = extract_links(emails[0].text)
|
||||
|
||||
resp = await gogo_client.get(links["confirm"])
|
||||
assert resp.status_code == 200
|
||||
assert "Termin potvrđen" in resp.text
|
||||
|
||||
await session.refresh(req)
|
||||
assert req.status == BookingStatus.confirmed.value
|
||||
assert len(sms) == 1
|
||||
assert "Potvrđen termin" in sms[0][1]
|
||||
|
||||
|
||||
async def test_reject_link_shows_editable_form_then_sends(
|
||||
session, tenant, emails, sms, gogo_client
|
||||
):
|
||||
req, _ = await create(session, tenant)
|
||||
await session.commit()
|
||||
links = extract_links(emails[0].text)
|
||||
|
||||
# GET shows the form with the default template prefilled — nothing sent yet
|
||||
resp = await gogo_client.get(links["reject"])
|
||||
assert resp.status_code == 200
|
||||
assert "<textarea" in resp.text
|
||||
assert sms == []
|
||||
|
||||
resp = await gogo_client.post(
|
||||
links["reject"] + "/reject", data={"sms_body": "Nažalost, popunjeni smo. Salon Merima"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
await session.refresh(req)
|
||||
assert req.status == BookingStatus.rejected.value
|
||||
assert sms == [("+38765123456", "Nažalost, popunjeni smo. Salon Merima")]
|
||||
|
||||
|
||||
async def test_forged_token_rejected(gogo_client, session, tenant):
|
||||
resp = await gogo_client.get("/a/forged-token-value")
|
||||
assert resp.status_code == 400
|
||||
assert "Nevažeći link" in resp.text
|
||||
|
||||
|
||||
async def test_action_after_expiry_shows_status(session, tenant, emails, sms, gogo_client):
|
||||
from gogo.proposals import engine as pe
|
||||
|
||||
req, _ = await create(session, tenant)
|
||||
req.expires_at = datetime.now(UTC) - timedelta(minutes=1)
|
||||
await pe.process_expirations(session)
|
||||
await session.commit()
|
||||
sms.clear()
|
||||
|
||||
links = extract_links(emails[0].text)
|
||||
resp = await gogo_client.get(links["confirm"])
|
||||
assert resp.status_code == 200
|
||||
assert "već obrađen" in resp.text
|
||||
assert "istekao" in resp.text
|
||||
assert sms == []
|
||||
|
||||
|
||||
async def test_confirm_link_warns_when_slot_taken(session, tenant, emails, sms, gogo_client):
|
||||
"""Recheck finds slot busy → warning page with force-confirm, no SMS yet."""
|
||||
from gogo.scheduling.mock import MockProvider
|
||||
|
||||
req, _ = await create(session, tenant)
|
||||
await session.commit()
|
||||
links = extract_links(emails[0].text)
|
||||
|
||||
async def is_slot_free(self, service_id, slot_):
|
||||
return False
|
||||
|
||||
MockProvider.is_slot_free = is_slot_free
|
||||
try:
|
||||
resp = await gogo_client.get(links["confirm"])
|
||||
assert resp.status_code == 200
|
||||
assert "u međuvremenu zauzet" in resp.text
|
||||
await session.refresh(req)
|
||||
assert req.status == BookingStatus.pending.value
|
||||
assert sms == []
|
||||
|
||||
# owner forces the confirmation anyway
|
||||
resp = await gogo_client.post(links["confirm"] + "/force-confirm")
|
||||
assert resp.status_code == 200
|
||||
assert "Termin potvrđen" in resp.text
|
||||
finally:
|
||||
del MockProvider.is_slot_free
|
||||
|
||||
await session.refresh(req)
|
||||
assert req.status == BookingStatus.confirmed.value
|
||||
assert len(sms) == 1
|
||||
263
tests/test_partner_api.py
Normal file
263
tests/test_partner_api.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""partner_api provider against the fake partner server (Appendix B):
|
||||
availability pull, request push + idempotency, catalog sync, webhook outcomes
|
||||
(signature, idempotent replay), polling fallback."""
|
||||
|
||||
import json
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from gogo.domain import BookingStatus, Slot
|
||||
from gogo.proposals import engine
|
||||
from gogo.scheduling.base import get_provider
|
||||
from tests.fake_partner import sign_webhook, staff_resolve
|
||||
|
||||
SLOT_1 = {"start": "2026-07-15T14:30:00+02:00", "end": "2026-07-15T15:15:00+02:00",
|
||||
"staff_id": "EMP-3", "staff_name": "Merima"}
|
||||
SLOT_2 = {"start": "2026-07-15T17:00:00+02:00", "end": "2026-07-15T17:45:00+02:00"}
|
||||
|
||||
|
||||
async def create_partner_request(session, partner_tenant, **kw):
|
||||
from gogo.models import Service
|
||||
|
||||
svc = (
|
||||
await session.execute(select(Service).where(Service.tenant_id == partner_tenant.id))
|
||||
).scalar_one()
|
||||
defaults = dict(
|
||||
service_id=str(svc.id),
|
||||
source="voice",
|
||||
client_name="Amra Hodžić",
|
||||
client_phone="+38765123456",
|
||||
service_name_raw="manikir",
|
||||
slots=[Slot.model_validate(SLOT_2)],
|
||||
summary="Klijentica želi manikir.",
|
||||
)
|
||||
defaults.update(kw)
|
||||
return await engine.create_booking_request(session, partner_tenant, **defaults)
|
||||
|
||||
|
||||
async def test_availability_pull(session, partner_tenant, wire_fake_partner):
|
||||
from datetime import date
|
||||
|
||||
wire_fake_partner.slots["SRV-12"] = [SLOT_1, SLOT_2]
|
||||
provider = await get_provider(session, partner_tenant)
|
||||
from gogo.models import Service
|
||||
|
||||
svc = (
|
||||
await session.execute(select(Service).where(Service.tenant_id == partner_tenant.id))
|
||||
).scalar_one()
|
||||
|
||||
slots = await provider.get_availability(str(svc.id), date(2026, 7, 15), date(2026, 7, 22))
|
||||
assert len(slots) == 2
|
||||
assert slots[0].staff_name == "Merima"
|
||||
assert slots[1].staff_name is None
|
||||
|
||||
|
||||
async def test_push_and_idempotency(session, partner_tenant, wire_fake_partner, emails, sms):
|
||||
req, result = await create_partner_request(session, partner_tenant)
|
||||
await session.commit()
|
||||
|
||||
assert result.ok
|
||||
assert result.partner_request_id == "REQ-0001"
|
||||
assert req.partner_request_id == "REQ-0001"
|
||||
# partner tenant default: no owner email (§8.2)
|
||||
assert emails == []
|
||||
|
||||
stored = wire_fake_partner.requests["REQ-0001"]
|
||||
assert stored["client"]["name"] == "Amra Hodžić"
|
||||
assert stored["service_id"] == "SRV-12" # mapped to the partner's id
|
||||
assert stored["source"] == "voice"
|
||||
|
||||
# replaying the same push (same Idempotency-Key) must not create a duplicate
|
||||
provider = await get_provider(session, partner_tenant)
|
||||
from gogo.domain import BookingRequestData
|
||||
from gogo.models import utcnow
|
||||
|
||||
replay = BookingRequestData(
|
||||
gogo_request_id=str(req.id),
|
||||
tenant_id=str(partner_tenant.id),
|
||||
created_at=utcnow(),
|
||||
source="voice",
|
||||
client_name=req.client_name,
|
||||
client_phone=req.client_phone,
|
||||
summary=req.summary,
|
||||
)
|
||||
result2 = await provider.deliver_request(replay)
|
||||
assert result2.partner_request_id == "REQ-0001"
|
||||
assert len(wire_fake_partner.requests) == 1
|
||||
|
||||
|
||||
async def test_dry_run_creates_nothing(session, partner_tenant, wire_fake_partner):
|
||||
from gogo.domain import BookingRequestData
|
||||
from gogo.models import utcnow
|
||||
|
||||
provider = await get_provider(session, partner_tenant)
|
||||
result = await provider.deliver_request(
|
||||
BookingRequestData(
|
||||
gogo_request_id="00000000-0000-0000-0000-000000000001",
|
||||
tenant_id=str(partner_tenant.id),
|
||||
created_at=utcnow(),
|
||||
source="chat",
|
||||
client_name="Test",
|
||||
client_phone="+38700000000",
|
||||
dry_run=True,
|
||||
)
|
||||
)
|
||||
assert result.ok
|
||||
assert wire_fake_partner.requests == {}
|
||||
|
||||
|
||||
async def test_catalog_sync(session, partner_tenant, wire_fake_partner):
|
||||
wire_fake_partner.services = [
|
||||
{"id": "SRV-12", "name": "Manikir", "duration_min": 45,
|
||||
"price_min": 20, "price_max": 25, "currency": "BAM",
|
||||
"home_visit": False, "active": True},
|
||||
{"id": "SRV-13", "name": "Gel nokti", "duration_min": 90,
|
||||
"price_min": 50, "price_max": 70, "currency": "BAM",
|
||||
"home_visit": False, "active": True},
|
||||
]
|
||||
provider = await get_provider(session, partner_tenant)
|
||||
services = await provider.get_services()
|
||||
assert [s.name for s in services] == ["Manikir", "Gel nokti"]
|
||||
assert services[1].duration_min == 90
|
||||
|
||||
|
||||
async def test_webhook_confirmed_sends_sms(
|
||||
session, partner_tenant, wire_fake_partner, gogo_client, sms
|
||||
):
|
||||
req, _ = await create_partner_request(session, partner_tenant)
|
||||
await session.commit()
|
||||
|
||||
resp = await staff_resolve(
|
||||
gogo_client, str(partner_tenant.id), "REQ-0001", "confirmed", confirmed_slot=SLOT_2
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
await session.refresh(req)
|
||||
assert req.status == BookingStatus.confirmed.value
|
||||
assert len(sms) == 1
|
||||
assert "Potvrđen termin" in sms[0][1]
|
||||
|
||||
# idempotent replay: same outcome again → ok, no second SMS
|
||||
resp = await staff_resolve(
|
||||
gogo_client, str(partner_tenant.id), "REQ-0001", "confirmed", confirmed_slot=SLOT_2
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(sms) == 1
|
||||
|
||||
|
||||
async def test_webhook_resolved_sends_nothing(
|
||||
session, partner_tenant, wire_fake_partner, gogo_client, sms
|
||||
):
|
||||
req, _ = await create_partner_request(session, partner_tenant)
|
||||
await session.commit()
|
||||
|
||||
resp = await staff_resolve(gogo_client, str(partner_tenant.id), "REQ-0001", "resolved")
|
||||
assert resp.status_code == 200
|
||||
await session.refresh(req)
|
||||
assert req.status == BookingStatus.resolved_by_owner.value
|
||||
assert sms == []
|
||||
|
||||
|
||||
async def test_webhook_rejected_sends_rejection_sms(
|
||||
session, partner_tenant, wire_fake_partner, gogo_client, sms
|
||||
):
|
||||
req, _ = await create_partner_request(session, partner_tenant)
|
||||
await session.commit()
|
||||
|
||||
resp = await staff_resolve(gogo_client, str(partner_tenant.id), "REQ-0001", "rejected")
|
||||
assert resp.status_code == 200
|
||||
await session.refresh(req)
|
||||
assert req.status == BookingStatus.rejected.value
|
||||
assert len(sms) == 1
|
||||
assert "nažalost" in sms[0][1].lower()
|
||||
|
||||
|
||||
async def test_webhook_bad_signature_rejected(
|
||||
session, partner_tenant, wire_fake_partner, gogo_client, sms
|
||||
):
|
||||
req, _ = await create_partner_request(session, partner_tenant)
|
||||
await session.commit()
|
||||
|
||||
body = json.dumps(
|
||||
{"gogo_request_id": str(req.id), "partner_request_id": "REQ-0001",
|
||||
"outcome": "confirmed", "confirmed_slot": SLOT_2}
|
||||
).encode()
|
||||
resp = await gogo_client.post(
|
||||
f"/webhooks/partner/{partner_tenant.id}",
|
||||
content=body,
|
||||
headers={
|
||||
"X-Gogo-Signature": sign_webhook(body, secret="wrong-secret"),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
await session.refresh(req)
|
||||
assert req.status == BookingStatus.pending.value
|
||||
assert sms == []
|
||||
|
||||
# missing header entirely
|
||||
resp = await gogo_client.post(
|
||||
f"/webhooks/partner/{partner_tenant.id}",
|
||||
content=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_webhook_confirmed_requires_slot(
|
||||
session, partner_tenant, wire_fake_partner, gogo_client
|
||||
):
|
||||
req, _ = await create_partner_request(session, partner_tenant)
|
||||
await session.commit()
|
||||
|
||||
body = json.dumps(
|
||||
{"gogo_request_id": str(req.id), "partner_request_id": "REQ-0001",
|
||||
"outcome": "confirmed"}
|
||||
).encode()
|
||||
resp = await gogo_client.post(
|
||||
f"/webhooks/partner/{partner_tenant.id}",
|
||||
content=body,
|
||||
headers={"X-Gogo-Signature": sign_webhook(body), "Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error"]["code"] == "missing_slot"
|
||||
|
||||
|
||||
async def test_webhook_conflicting_outcome_409(
|
||||
session, partner_tenant, wire_fake_partner, gogo_client, sms
|
||||
):
|
||||
req, _ = await create_partner_request(session, partner_tenant)
|
||||
await session.commit()
|
||||
|
||||
resp = await staff_resolve(gogo_client, str(partner_tenant.id), "REQ-0001", "resolved")
|
||||
assert resp.status_code == 200
|
||||
resp = await staff_resolve(
|
||||
gogo_client, str(partner_tenant.id), "REQ-0001", "confirmed", confirmed_slot=SLOT_2
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
async def test_polling_fallback(session, partner_tenant, wire_fake_partner, sms, monkeypatch):
|
||||
|
||||
from gogo import jobs
|
||||
from gogo.models import ProviderConfig
|
||||
|
||||
# enable polling for the tenant
|
||||
cfg = (
|
||||
await session.execute(
|
||||
select(ProviderConfig).where(ProviderConfig.tenant_id == partner_tenant.id)
|
||||
)
|
||||
).scalar_one()
|
||||
cfg.config = {**cfg.config, "polling_fallback": True}
|
||||
req, _ = await create_partner_request(session, partner_tenant)
|
||||
await session.commit()
|
||||
|
||||
# staff confirm inside the partner software only (no webhook call)
|
||||
wire_fake_partner.requests["REQ-0001"]["status"] = "confirmed"
|
||||
wire_fake_partner.requests["REQ-0001"]["confirmed_slot"] = SLOT_2
|
||||
|
||||
await jobs.run_partner_polling_job()
|
||||
|
||||
await session.refresh(req)
|
||||
assert req.status == BookingStatus.confirmed.value
|
||||
assert len(sms) == 1
|
||||
169
tests/test_proposal_lifecycle.py
Normal file
169
tests/test_proposal_lifecycle.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Proposal engine end-to-end against the mock provider (§9, M1)."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from gogo.domain import BookingStatus, Slot
|
||||
from gogo.models import ActionToken
|
||||
from gogo.proposals import engine
|
||||
|
||||
TZ = ZoneInfo("Europe/Sarajevo")
|
||||
|
||||
|
||||
def slot(days_ahead: int = 2, hour: int = 17) -> Slot:
|
||||
base = datetime.now(TZ).replace(hour=hour, minute=0, second=0, microsecond=0)
|
||||
start = base + timedelta(days=days_ahead)
|
||||
return Slot(start=start, end=start + timedelta(minutes=45))
|
||||
|
||||
|
||||
async def create(session, tenant, **kw):
|
||||
defaults = dict(
|
||||
source="voice",
|
||||
client_name="Amra Hodžić",
|
||||
client_phone="+38765123456",
|
||||
service_name_raw="šišanje i feniranje",
|
||||
slots=[slot()],
|
||||
summary="Klijentica želi šišanje i feniranje.",
|
||||
)
|
||||
defaults.update(kw)
|
||||
return await engine.create_booking_request(session, tenant, **defaults)
|
||||
|
||||
|
||||
async def test_create_sends_email_with_actions_and_ics(session, tenant, emails, sms):
|
||||
req, result = await create(session, tenant)
|
||||
await session.commit()
|
||||
|
||||
assert result.ok
|
||||
assert req.status == BookingStatus.pending.value
|
||||
assert req.expires_at is not None
|
||||
|
||||
assert len(emails) == 1
|
||||
mail = emails[0]
|
||||
assert mail.to == ["merima@example.ba"]
|
||||
assert "Novi zahtjev za termin" in mail.subject
|
||||
assert "Amra Hodžić" in mail.subject
|
||||
assert "+38765123456" in mail.text
|
||||
assert "RIJEŠENO" in mail.text
|
||||
assert "POTVRDI" in mail.text
|
||||
assert "ODBIJ" in mail.text
|
||||
# .ics attachment with METHOD:REQUEST
|
||||
assert mail.attachments and mail.attachments[0][0] == "termin.ics"
|
||||
assert b"METHOD:REQUEST" in mail.attachments[0][2]
|
||||
assert b"BEGIN:VEVENT" in mail.attachments[0][2]
|
||||
|
||||
# action tokens persisted: resolve + confirm(1 slot) + reject
|
||||
tokens = (await session.execute(select(ActionToken))).scalars().all()
|
||||
assert {t.action for t in tokens} == {"resolve", "confirm", "reject"}
|
||||
|
||||
# no client SMS by default (sms_request_received off)
|
||||
assert sms == []
|
||||
|
||||
|
||||
async def test_request_received_sms_when_enabled(session, tenant, emails, sms):
|
||||
tenant.sms_request_received = True
|
||||
await create(session, tenant)
|
||||
await session.commit()
|
||||
assert len(sms) == 1
|
||||
assert sms[0][0] == "+38765123456"
|
||||
assert "Primili smo vaš zahtjev" in sms[0][1]
|
||||
|
||||
|
||||
async def test_resolve_sends_no_sms(session, tenant, emails, sms):
|
||||
req, _ = await create(session, tenant)
|
||||
await engine.resolve_request(session, tenant, req)
|
||||
await session.commit()
|
||||
assert req.status == BookingStatus.resolved_by_owner.value
|
||||
assert req.resolved_at is not None
|
||||
assert sms == [] # primary flow: owner contacted client directly, Gogo sends nothing
|
||||
|
||||
|
||||
async def test_confirm_sends_confirmation_sms(session, tenant, emails, sms):
|
||||
s = slot()
|
||||
req, _ = await create(session, tenant, slots=[s])
|
||||
ok = await engine.confirm_request(session, tenant, req, s)
|
||||
await session.commit()
|
||||
assert ok
|
||||
assert req.status == BookingStatus.confirmed.value
|
||||
assert len(sms) == 1
|
||||
to, body = sms[0]
|
||||
assert to == "+38765123456"
|
||||
assert "Potvrđen termin" in body
|
||||
assert "Salon Merima" in body
|
||||
|
||||
|
||||
async def test_confirm_recheck_busy_blocks_transition(
|
||||
session, tenant, emails, sms, clean_mock_provider
|
||||
):
|
||||
s = slot()
|
||||
req, _ = await create(session, tenant, slots=[s])
|
||||
# is_slot_free is only defined for providers that compute availability;
|
||||
# mock has no is_slot_free → recheck is skipped. Simulate a gcal-style
|
||||
# provider recheck by attaching one.
|
||||
from gogo.scheduling.mock import MockProvider
|
||||
|
||||
async def is_slot_free(self, service_id, slot_):
|
||||
return False
|
||||
|
||||
MockProvider.is_slot_free = is_slot_free
|
||||
try:
|
||||
ok = await engine.confirm_request(session, tenant, req, s, recheck=True)
|
||||
finally:
|
||||
del MockProvider.is_slot_free
|
||||
assert not ok
|
||||
assert req.status == BookingStatus.pending.value
|
||||
assert sms == []
|
||||
|
||||
|
||||
async def test_reject_sends_sms_with_custom_text(session, tenant, emails, sms):
|
||||
req, _ = await create(session, tenant)
|
||||
await engine.reject_request(session, tenant, req, custom_sms="Nažalost ne može ovaj termin.")
|
||||
await session.commit()
|
||||
assert req.status == BookingStatus.rejected.value
|
||||
assert sms == [("+38765123456", "Nažalost ne može ovaj termin.")]
|
||||
|
||||
|
||||
async def test_transitions_are_exclusive(session, tenant, emails, sms):
|
||||
req, _ = await create(session, tenant)
|
||||
await engine.resolve_request(session, tenant, req)
|
||||
with pytest.raises(engine.TransitionError):
|
||||
await engine.reject_request(session, tenant, req)
|
||||
# idempotent replay of same action is fine
|
||||
await engine.resolve_request(session, tenant, req)
|
||||
assert req.status == BookingStatus.resolved_by_owner.value
|
||||
|
||||
|
||||
async def test_expiry_job_expires_and_sends_apology(session, tenant, emails, sms):
|
||||
req, _ = await create(session, tenant)
|
||||
req.expires_at = datetime.now(UTC) - timedelta(minutes=1)
|
||||
await session.flush()
|
||||
expired = await engine.process_expirations(session)
|
||||
await session.commit()
|
||||
assert expired == 1
|
||||
assert req.status == BookingStatus.expired.value
|
||||
assert len(sms) == 1
|
||||
assert "pozovite nas ponovo" in sms[0][1].lower() or "pozovite" in sms[0][1].lower()
|
||||
|
||||
|
||||
async def test_reminder_email_at_half_ttl(session, tenant, emails, sms):
|
||||
req, _ = await create(session, tenant)
|
||||
emails.clear()
|
||||
# 24h TTL → reminder when <= 12h remain
|
||||
req.expires_at = datetime.now(UTC) + timedelta(hours=11)
|
||||
await session.flush()
|
||||
expired = await engine.process_expirations(session)
|
||||
await session.commit()
|
||||
assert expired == 0
|
||||
assert req.reminder_sent
|
||||
assert len(emails) == 1
|
||||
assert "Podsjetnik" in emails[0].subject
|
||||
# second run must not re-send
|
||||
await engine.process_expirations(session)
|
||||
assert len(emails) == 1
|
||||
|
||||
|
||||
async def test_slots_capped_at_three(session, tenant, emails, sms):
|
||||
req, _ = await create(session, tenant, slots=[slot(1), slot(2), slot(3), slot(4), slot(5)])
|
||||
assert len(req.slots) == 3
|
||||
96
tests/test_slots.py
Normal file
96
tests/test_slots.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Slot computation (§8.1): working hours − busy, quantization, notice, horizon."""
|
||||
|
||||
from datetime import date, datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from gogo.scheduling.slots import compute_slots, subtract_busy
|
||||
|
||||
TZ = ZoneInfo("Europe/Sarajevo")
|
||||
|
||||
|
||||
def dt(day: int, h: int, m: int = 0) -> datetime:
|
||||
return datetime(2026, 7, day, h, m, tzinfo=TZ)
|
||||
|
||||
|
||||
WH = {
|
||||
"mon": [["09:00", "13:00"], ["14:00", "18:00"]], # lunch break
|
||||
"tue": [["09:00", "18:00"]],
|
||||
"wed": [["09:00", "18:00"]],
|
||||
"thu": [["09:00", "18:00"]],
|
||||
"fri": [["09:00", "18:00"]],
|
||||
"sat": [["09:00", "14:00"]],
|
||||
"sun": [],
|
||||
}
|
||||
|
||||
# Mon 2026-07-13 .. Sun 2026-07-19
|
||||
NOW = dt(13, 8, 0) # Monday 08:00
|
||||
|
||||
|
||||
def test_subtract_busy_splits_interval():
|
||||
free = subtract_busy((dt(13, 9), dt(13, 13)), [(dt(13, 10), dt(13, 11))])
|
||||
assert free == [(dt(13, 9), dt(13, 10)), (dt(13, 11), dt(13, 13))]
|
||||
|
||||
|
||||
def test_subtract_busy_no_overlap():
|
||||
free = subtract_busy((dt(13, 9), dt(13, 13)), [(dt(13, 14), dt(13, 15))])
|
||||
assert free == [(dt(13, 9), dt(13, 13))]
|
||||
|
||||
|
||||
def test_slots_respect_break_and_duration():
|
||||
slots = compute_slots(
|
||||
working_hours=WH, busy=[], duration_min=60, date_from=date(2026, 7, 13),
|
||||
date_to=date(2026, 7, 13), tz=TZ, now=NOW, min_notice_hours=0, max_slots=50,
|
||||
)
|
||||
starts = [s.start for s in slots]
|
||||
# no slot may span the 13:00-14:00 break
|
||||
assert dt(13, 13) not in starts
|
||||
for s in slots:
|
||||
assert not (s.start < dt(13, 14) and s.end > dt(13, 13))
|
||||
assert slots[0].start == dt(13, 9)
|
||||
assert all((s.end - s.start).total_seconds() == 3600 for s in slots)
|
||||
|
||||
|
||||
def test_min_notice_pushes_first_slot():
|
||||
slots = compute_slots(
|
||||
working_hours=WH, busy=[], duration_min=30, date_from=date(2026, 7, 13),
|
||||
date_to=date(2026, 7, 13), tz=TZ, now=dt(13, 9, 5), min_notice_hours=2,
|
||||
)
|
||||
assert slots[0].start >= dt(13, 11, 5)
|
||||
|
||||
|
||||
def test_busy_blocks_remove_slots():
|
||||
slots = compute_slots(
|
||||
working_hours=WH, busy=[(dt(14, 9), dt(14, 12))], duration_min=60,
|
||||
date_from=date(2026, 7, 14), date_to=date(2026, 7, 14), tz=TZ, now=NOW,
|
||||
min_notice_hours=0,
|
||||
)
|
||||
assert slots[0].start == dt(14, 12)
|
||||
|
||||
|
||||
def test_sunday_closed():
|
||||
slots = compute_slots(
|
||||
working_hours=WH, busy=[], duration_min=30, date_from=date(2026, 7, 19),
|
||||
date_to=date(2026, 7, 19), tz=TZ, now=NOW, min_notice_hours=0,
|
||||
)
|
||||
assert slots == []
|
||||
|
||||
|
||||
def test_max_days_ahead_horizon():
|
||||
slots = compute_slots(
|
||||
working_hours=WH, busy=[], duration_min=30, date_from=date(2026, 7, 13),
|
||||
date_to=date(2026, 8, 30), tz=TZ, now=NOW, min_notice_hours=0,
|
||||
max_days_ahead=2, max_slots=1000,
|
||||
)
|
||||
assert max(s.start for s in slots).date() <= date(2026, 7, 15)
|
||||
|
||||
|
||||
def test_buffer_extends_step():
|
||||
slots = compute_slots(
|
||||
working_hours=WH, busy=[], duration_min=45, buffer_min=15,
|
||||
date_from=date(2026, 7, 14), date_to=date(2026, 7, 14), tz=TZ, now=NOW,
|
||||
min_notice_hours=0,
|
||||
)
|
||||
# steps of 60 (45+15), but slot end = start + 45
|
||||
assert slots[0].start == dt(14, 9)
|
||||
assert slots[0].end == dt(14, 9, 45)
|
||||
assert slots[1].start == dt(14, 10)
|
||||
67
tests/test_units.py
Normal file
67
tests/test_units.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Small unit tests: working hours, Bosnian formatting, SMS templates, crypto."""
|
||||
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from gogo import crypto
|
||||
from gogo.hours import DEFAULT_WORKING_HOURS, hours_summary_bs, is_open_at
|
||||
from gogo.i18n import fmt_price, fmt_slot
|
||||
from gogo.models import Tenant
|
||||
from gogo.sms.templates import render_sms
|
||||
|
||||
TZ = ZoneInfo("Europe/Sarajevo")
|
||||
|
||||
|
||||
def test_is_open_at():
|
||||
wh = DEFAULT_WORKING_HOURS
|
||||
assert is_open_at(wh, datetime(2026, 7, 15, 10, 0, tzinfo=TZ), TZ) # Wed 10:00
|
||||
assert not is_open_at(wh, datetime(2026, 7, 15, 20, 0, tzinfo=TZ), TZ) # Wed 20:00
|
||||
assert not is_open_at(wh, datetime(2026, 7, 19, 10, 0, tzinfo=TZ), TZ) # Sun
|
||||
assert is_open_at(wh, datetime(2026, 7, 18, 13, 59, tzinfo=TZ), TZ) # Sat 13:59
|
||||
assert not is_open_at(wh, datetime(2026, 7, 18, 14, 0, tzinfo=TZ), TZ) # Sat 14:00
|
||||
|
||||
|
||||
def test_hours_summary_bs():
|
||||
s = hours_summary_bs(DEFAULT_WORKING_HOURS)
|
||||
assert "ponedjeljak: 09:00–18:00" in s
|
||||
assert "nedjelja: zatvoreno" in s
|
||||
|
||||
|
||||
def test_fmt_slot_bosnian():
|
||||
dt = datetime(2026, 7, 15, 17, 0, tzinfo=TZ) # Wednesday
|
||||
assert fmt_slot(dt) == "srijeda, 15.07. u 17:00"
|
||||
|
||||
|
||||
def test_fmt_price():
|
||||
assert fmt_price(60, 90) == "60–90 KM"
|
||||
assert fmt_price(25, 25) == "25 KM"
|
||||
assert fmt_price(30, None) == "30 KM"
|
||||
assert fmt_price(None, None) == "cijena na upit"
|
||||
|
||||
|
||||
def make_tenant(**kw):
|
||||
defaults = dict(name="Salon Merima", sms_templates={})
|
||||
defaults.update(kw)
|
||||
return Tenant(**defaults)
|
||||
|
||||
|
||||
def test_sms_default_templates():
|
||||
t = make_tenant()
|
||||
body = render_sms(
|
||||
t, "confirmation", usluga="Manikir", dan="srijeda", datum="15.07.2026.", vrijeme="17:00"
|
||||
)
|
||||
assert body == "Potvrđen termin: Manikir, srijeda 15.07.2026. u 17:00h — Salon Merima."
|
||||
|
||||
|
||||
def test_sms_tenant_override():
|
||||
t = make_tenant(sms_templates={"rejection": "{salon}: ne može, žao nam je."})
|
||||
assert render_sms(t, "rejection") == "Salon Merima: ne može, žao nam je."
|
||||
|
||||
|
||||
def test_sms_unknown_placeholder_left_intact():
|
||||
t = make_tenant(sms_templates={"missed_call": "Pozovite {salon} — {nepoznato}"})
|
||||
assert render_sms(t, "missed_call", link="x") == "Pozovite Salon Merima — {nepoznato}"
|
||||
|
||||
|
||||
def test_crypto_roundtrip():
|
||||
assert crypto.decrypt(crypto.encrypt("tajna-lozinka")) == "tajna-lozinka"
|
||||
Reference in New Issue
Block a user