Files
clientflow_backend/app/db.py
2026-06-09 22:55:58 +01:00

325 lines
14 KiB
Python

from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from app.config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=engine,
)
_CORE_SCHEMA_READY = False
def ensure_core_schema() -> None:
"""Cria o schema base do ClientFlow numa base PostgreSQL vazia.
As versões anteriores assumiam que `tasks`, `messages`, `raw_events` e
`action_runs` já existiam. Numa instalação limpa isso fazia o arranque
falhar quando a camada de oportunidades tentava executar:
ALTER TABLE tasks ADD COLUMN ...
Esta função é aditiva e segura para bases existentes: cria tabelas e
índices apenas se não existirem e adiciona colunas opcionais em falta.
"""
global _CORE_SCHEMA_READY
if _CORE_SCHEMA_READY:
return
with engine.begin() as conn:
# Necessário para DEFAULT gen_random_uuid(). Em PostgreSQL moderno,
# pgcrypto é a forma mais simples de gerar UUIDs no próprio servidor.
conn.execute(text("CREATE EXTENSION IF NOT EXISTS pgcrypto"))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS raw_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_system TEXT NOT NULL DEFAULT 'clientflow',
event_type TEXT,
source_event_id TEXT,
conversation_id TEXT,
contact_id TEXT,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
processed BOOLEAN NOT NULL DEFAULT FALSE,
ignored BOOLEAN NOT NULL DEFAULT FALSE,
processing_error TEXT,
message_id UUID,
action_run_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ
)
"""))
conn.execute(text("""
CREATE UNIQUE INDEX IF NOT EXISTS ux_raw_events_source_event
ON raw_events(source_system, source_event_id)
WHERE source_event_id IS NOT NULL
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
raw_event_id UUID,
source_system TEXT NOT NULL DEFAULT 'clientflow',
source_event_id TEXT,
conversation_id TEXT,
contact_id TEXT,
direction TEXT NOT NULL DEFAULT 'inbound',
raw_body TEXT,
clean_body TEXT,
previous_context TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS action_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID,
raw_event_id UUID,
conversation_id TEXT,
contact_id TEXT,
source_system TEXT NOT NULL DEFAULT 'clientflow',
model TEXT,
provider TEXT,
openrouter_generation_id TEXT,
decision_source TEXT,
action_decision JSONB NOT NULL DEFAULT '{}'::jsonb,
action_result JSONB NOT NULL DEFAULT '{}'::jsonb,
prompt_tokens INTEGER,
completion_tokens INTEGER,
total_tokens INTEGER,
cost NUMERIC(12,6),
usage JSONB NOT NULL DEFAULT '{}'::jsonb,
needs_review BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
action_run_id UUID,
message_id UUID,
raw_event_id UUID,
opportunity_id UUID,
conversation_id TEXT,
contact_id TEXT,
customer_id TEXT,
action_code TEXT NOT NULL DEFAULT 'REVIEW_MANUALLY',
route TEXT NOT NULL DEFAULT 'rever',
action TEXT NOT NULL DEFAULT 'Rever manualmente',
note TEXT,
action_required BOOLEAN NOT NULL DEFAULT FALSE,
safe_to_post BOOLEAN NOT NULL DEFAULT FALSE,
status TEXT NOT NULL DEFAULT 'pending',
source_system TEXT NOT NULL DEFAULT 'clientflow',
source_event_id TEXT,
idempotency_key TEXT,
due_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
done_at TIMESTAMPTZ,
done_by TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
)
"""))
# Colunas adicionadas em upgrades anteriores. Mantidas aqui para
# compatibilidade quando a tabela já existe numa base antiga.
for statement in [
"ALTER TABLE tasks ADD COLUMN IF NOT EXISTS opportunity_id UUID",
"ALTER TABLE tasks ADD COLUMN IF NOT EXISTS due_at TIMESTAMPTZ",
"ALTER TABLE tasks ADD COLUMN IF NOT EXISTS done_at TIMESTAMPTZ",
"ALTER TABLE tasks ADD COLUMN IF NOT EXISTS done_by TEXT",
"ALTER TABLE tasks ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'::jsonb",
"ALTER TABLE tasks ADD COLUMN IF NOT EXISTS idempotency_key TEXT",
]:
conn.execute(text(statement))
conn.execute(text("""
CREATE UNIQUE INDEX IF NOT EXISTS ux_tasks_idempotency_key
ON tasks(idempotency_key)
WHERE idempotency_key IS NOT NULL
"""))
conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_idempotency_key ON tasks(idempotency_key)"))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS task_preparations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL,
conversation_id TEXT,
contact_id TEXT,
prep_type TEXT NOT NULL DEFAULT 'generic',
status TEXT NOT NULL DEFAULT 'draft',
extracted_data JSONB NOT NULL DEFAULT '{}'::jsonb,
missing_fields JSONB NOT NULL DEFAULT '[]'::jsonb,
suggested_reply TEXT,
confidence NUMERIC(4,3),
model TEXT,
provider TEXT,
total_tokens INTEGER NOT NULL DEFAULT 0,
cost NUMERIC(12,6) NOT NULL DEFAULT 0,
raw_response JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_task_preparations_task ON task_preparations(task_id, created_at DESC)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_task_preparations_conversation ON task_preparations(conversation_id)"))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS task_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_by TEXT NOT NULL DEFAULT 'system',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS business_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type TEXT NOT NULL,
task_id UUID,
action_run_id UUID,
message_id UUID,
raw_event_id UUID,
customer_id TEXT,
conversation_id TEXT,
contact_id TEXT,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
idempotency_key TEXT,
created_by TEXT NOT NULL DEFAULT 'system',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""))
conn.execute(text("""
CREATE UNIQUE INDEX IF NOT EXISTS ux_business_events_idempotency_key
ON business_events(idempotency_key)
WHERE idempotency_key IS NOT NULL
"""))
conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_business_events_idempotency_key ON business_events(idempotency_key)"))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS integration_outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
business_event_id UUID,
target_system TEXT NOT NULL,
action_type TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'pending',
retry_count INTEGER NOT NULL DEFAULT 0,
idempotency_key TEXT,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_at TIMESTAMPTZ
)
"""))
for statement in [
"ALTER TABLE integration_outbox ADD COLUMN IF NOT EXISTS locked_at TIMESTAMPTZ",
"ALTER TABLE integration_outbox ADD COLUMN IF NOT EXISTS lock_owner TEXT",
"ALTER TABLE integration_outbox ADD COLUMN IF NOT EXISTS ignored_at TIMESTAMPTZ",
]:
conn.execute(text(statement))
conn.execute(text("""
CREATE UNIQUE INDEX IF NOT EXISTS ux_integration_outbox_idempotency_key
ON integration_outbox(idempotency_key)
WHERE idempotency_key IS NOT NULL
"""))
conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_integration_outbox_idempotency_key ON integration_outbox(idempotency_key)"))
conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_integration_outbox_pending_dispatch
ON integration_outbox(target_system, created_at)
WHERE status = 'pending'
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS external_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
local_system TEXT NOT NULL DEFAULT 'clientflow',
local_entity_type TEXT NOT NULL,
local_entity_id TEXT NOT NULL,
external_system TEXT NOT NULL,
external_entity_type TEXT NOT NULL,
external_entity_id TEXT,
external_url TEXT,
match_key TEXT,
match_value TEXT,
confidence NUMERIC(4,3),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""))
# Índices de consulta mais usados no admin e webhooks.
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_tasks_route ON tasks(route)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_tasks_conversation ON tasks(conversation_id)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_tasks_contact ON tasks(contact_id)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks(created_at DESC)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_raw_events_created_at ON raw_events(created_at DESC)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_raw_events_conversation ON raw_events(conversation_id)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_action_runs_created_at ON action_runs(created_at DESC)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_task_events_task ON task_events(task_id, created_at DESC)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_business_events_created_at ON business_events(created_at DESC)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_outbox_status ON integration_outbox(status)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_outbox_target ON integration_outbox(target_system)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_external_mappings_local ON external_mappings(local_system, local_entity_type, local_entity_id)"))
_CORE_SCHEMA_READY = True
def init_db() -> None:
"""Inicialização do schema ClientFlow.
Ordem importante para bases novas:
1. core: raw_events/messages/action_runs/tasks/outbox;
2. oportunidades: depende de tasks;
3. produtos: depende de opportunities.
"""
try:
ensure_core_schema()
from app.opportunity_service import ensure_opportunity_schema
from app.product_service import ensure_product_schema
from app.operation_service import ensure_operation_schema
from app.commercial_service import ensure_commercial_schema
from app.communication_service import ensure_communication_schema
from app.reconciliation_service import ensure_reconciliation_schema
ensure_opportunity_schema()
ensure_product_schema()
ensure_operation_schema()
ensure_commercial_schema()
ensure_communication_schema()
ensure_reconciliation_schema()
except Exception as exc:
print(f"ClientFlow schema init failed: {exc}", flush=True)
raise
return None