Commita governance, user-wizard, operational-feed e catálogo RBAC; adiciona deploy-desk-full.sh, smoke-desk.sh e regra anti-deploy parcial; documenta credencial VM112 @betinplace. Co-authored-by: Cursor <cursoragent@cursor.com>
338 lines
12 KiB
Python
338 lines
12 KiB
Python
"""Operational feed mock store. Spec 041 · OF-API-001."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
from datetime import datetime, timezone, timedelta
|
|
from typing import Any
|
|
|
|
CHANNELS = (
|
|
("all", "Todos os canais"),
|
|
("email", "Email"),
|
|
("whatsapp", "WhatsApp API"),
|
|
("voice", "Telefonia / Voz"),
|
|
("sms", "SMS"),
|
|
("telegram", "Telegram"),
|
|
("tickets", "Tickets"),
|
|
("agents", "Agentes IA"),
|
|
("internal", "Solicitações internas"),
|
|
("clients", "Clientes"),
|
|
("alerts", "Alertas sistema"),
|
|
)
|
|
|
|
PRIORITIES = ("normal", "high", "critical")
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def _minutes_ago(minutes: int) -> str:
|
|
return (datetime.now(timezone.utc) - timedelta(minutes=minutes)).isoformat()
|
|
|
|
|
|
def init_inbox_schema(conn: sqlite3.Connection) -> None:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS ops_inbox_events (
|
|
id TEXT PRIMARY KEY,
|
|
channel TEXT NOT NULL,
|
|
event_type TEXT NOT NULL,
|
|
priority TEXT NOT NULL DEFAULT 'normal',
|
|
title TEXT NOT NULL,
|
|
preview TEXT NOT NULL,
|
|
tags_json TEXT NOT NULL DEFAULT '[]',
|
|
assignee TEXT,
|
|
status TEXT NOT NULL DEFAULT 'open',
|
|
contact_name TEXT,
|
|
contact_company TEXT,
|
|
contact_cnpj TEXT,
|
|
contact_client_id TEXT,
|
|
sla_minutes INTEGER NOT NULL DEFAULT 60,
|
|
sla_remaining_sec INTEGER NOT NULL DEFAULT 3600,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS ops_inbox_messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
event_id TEXT NOT NULL,
|
|
author_type TEXT NOT NULL,
|
|
author_label TEXT NOT NULL,
|
|
body TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
FOREIGN KEY (event_id) REFERENCES ops_inbox_events(id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_ops_inbox_events_channel ON ops_inbox_events(channel);
|
|
CREATE INDEX IF NOT EXISTS idx_ops_inbox_events_status ON ops_inbox_events(status);
|
|
"""
|
|
)
|
|
count = conn.execute("SELECT COUNT(*) c FROM ops_inbox_events").fetchone()["c"]
|
|
if count == 0:
|
|
_seed_inbox(conn)
|
|
|
|
|
|
def _seed_inbox(conn: sqlite3.Connection) -> None:
|
|
now = _now()
|
|
seeds = [
|
|
{
|
|
"id": "evt-wa-001",
|
|
"channel": "whatsapp",
|
|
"event_type": "message",
|
|
"priority": "high",
|
|
"title": "Cliente: Empresa Alpha — Não consigo acessar o painel financeiro",
|
|
"preview": "Bom dia, após o login aparece erro 403 no módulo billing…",
|
|
"tags": ["Cliente", "Acesso", "Infraestrutura"],
|
|
"assignee": "Editor",
|
|
"status": "open",
|
|
"contact_name": "Empresa Alpha",
|
|
"contact_company": "Empresa Alpha Ltda",
|
|
"contact_cnpj": "12.345.678/0001-90",
|
|
"contact_client_id": "CLI-8842",
|
|
"sla_minutes": 15,
|
|
"sla_remaining_sec": 750,
|
|
"created_at": _minutes_ago(2),
|
|
},
|
|
{
|
|
"id": "evt-int-002",
|
|
"channel": "internal",
|
|
"event_type": "request",
|
|
"priority": "normal",
|
|
"title": "Carlos — Financeiro: Aprovar reembolso #8821",
|
|
"preview": "Solicitação interna aguardando aprovação do Chefe Ops",
|
|
"tags": ["Interno", "Financeiro"],
|
|
"assignee": "Super Admin",
|
|
"status": "pending",
|
|
"contact_name": "Carlos Mendes",
|
|
"contact_company": "Ligbox Ops",
|
|
"contact_cnpj": "",
|
|
"contact_client_id": "",
|
|
"sla_minutes": 120,
|
|
"sla_remaining_sec": 5400,
|
|
"created_at": _minutes_ago(18),
|
|
},
|
|
{
|
|
"id": "evt-ai-003",
|
|
"channel": "agents",
|
|
"event_type": "alert",
|
|
"priority": "critical",
|
|
"title": "Agente A3 — CPU VM122 acima de 92%",
|
|
"preview": "Alerta automático: load average 4.2 — recomendação de escala",
|
|
"tags": ["Agente IA", "Infra", "VM122"],
|
|
"assignee": "AI Agent",
|
|
"status": "open",
|
|
"contact_name": "Watchman A3",
|
|
"contact_company": "Ligbox Platform",
|
|
"contact_cnpj": "",
|
|
"contact_client_id": "",
|
|
"sla_minutes": 30,
|
|
"sla_remaining_sec": 1200,
|
|
"created_at": _minutes_ago(5),
|
|
},
|
|
{
|
|
"id": "evt-em-004",
|
|
"channel": "email",
|
|
"event_type": "message",
|
|
"priority": "normal",
|
|
"title": "Empresa Beta — Pedido de upgrade de plano",
|
|
"preview": "Gostaríamos de migrar para o plano enterprise…",
|
|
"tags": ["Cliente", "Comercial"],
|
|
"assignee": "Sales Admin",
|
|
"status": "open",
|
|
"contact_name": "Empresa Beta",
|
|
"contact_company": "Beta Serviços SA",
|
|
"contact_cnpj": "98.765.432/0001-10",
|
|
"contact_client_id": "CLI-1201",
|
|
"sla_minutes": 240,
|
|
"sla_remaining_sec": 12000,
|
|
"created_at": _minutes_ago(45),
|
|
},
|
|
{
|
|
"id": "evt-tk-005",
|
|
"channel": "tickets",
|
|
"event_type": "ticket",
|
|
"priority": "high",
|
|
"title": "Ticket #4412 — Erro importação DNS",
|
|
"preview": "Falha ao sincronizar zona dns.example.com",
|
|
"tags": ["Ticket", "DNS"],
|
|
"assignee": "NOC",
|
|
"status": "open",
|
|
"contact_name": "Suporte N1",
|
|
"contact_company": "Cliente Gamma",
|
|
"contact_cnpj": "",
|
|
"contact_client_id": "CLI-3300",
|
|
"sla_minutes": 60,
|
|
"sla_remaining_sec": 2100,
|
|
"created_at": _minutes_ago(12),
|
|
},
|
|
{
|
|
"id": "evt-vc-006",
|
|
"channel": "voice",
|
|
"event_type": "missed_call",
|
|
"priority": "normal",
|
|
"title": "Chamada perdida — +55 11 98765-4321",
|
|
"preview": "Duração 0s · fila comercial",
|
|
"tags": ["Telefonia"],
|
|
"assignee": None,
|
|
"status": "open",
|
|
"contact_name": "Desconhecido",
|
|
"contact_company": "",
|
|
"contact_cnpj": "",
|
|
"contact_client_id": "",
|
|
"sla_minutes": 30,
|
|
"sla_remaining_sec": 900,
|
|
"created_at": _minutes_ago(8),
|
|
},
|
|
]
|
|
for s in seeds:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO ops_inbox_events
|
|
(id, channel, event_type, priority, title, preview, tags_json, assignee, status,
|
|
contact_name, contact_company, contact_cnpj, contact_client_id,
|
|
sla_minutes, sla_remaining_sec, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
s["id"], s["channel"], s["event_type"], s["priority"], s["title"], s["preview"],
|
|
json.dumps(s["tags"]), s["assignee"], s["status"],
|
|
s["contact_name"], s["contact_company"], s["contact_cnpj"], s["contact_client_id"],
|
|
s["sla_minutes"], s["sla_remaining_sec"], s["created_at"], s["created_at"],
|
|
),
|
|
)
|
|
msgs = [
|
|
("evt-wa-001", "user", "Empresa Alpha", "Bom dia, não consigo acessar o painel financeiro."),
|
|
("evt-wa-001", "agent", "AI Agent", "Detectei erro 403 no módulo billing. Encaminhei para suporte."),
|
|
("evt-wa-001", "system", "Sistema", "SLA iniciado — 15 min"),
|
|
]
|
|
for event_id, author_type, author_label, body in msgs:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO ops_inbox_messages (event_id, author_type, author_label, body, created_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
""",
|
|
(event_id, author_type, author_label, body, now),
|
|
)
|
|
|
|
|
|
def inbox_stats(conn: sqlite3.Connection) -> dict:
|
|
total = conn.execute("SELECT COUNT(*) c FROM ops_inbox_events").fetchone()["c"]
|
|
pending = conn.execute(
|
|
"SELECT COUNT(*) c FROM ops_inbox_events WHERE status IN ('open','pending')"
|
|
).fetchone()["c"]
|
|
critical = conn.execute(
|
|
"SELECT COUNT(*) c FROM ops_inbox_events WHERE priority = 'critical'"
|
|
).fetchone()["c"]
|
|
channels = []
|
|
for cid, label in CHANNELS:
|
|
if cid == "all":
|
|
cnt = total
|
|
else:
|
|
cnt = conn.execute(
|
|
"SELECT COUNT(*) c FROM ops_inbox_events WHERE channel = ?",
|
|
(cid,),
|
|
).fetchone()["c"]
|
|
channels.append({"id": cid, "label": label, "count": cnt})
|
|
return {
|
|
"events_today": total,
|
|
"pending": pending,
|
|
"critical": critical,
|
|
"awaiting_you": conn.execute(
|
|
"SELECT COUNT(*) c FROM ops_inbox_events WHERE assignee IS NOT NULL AND status = 'open'"
|
|
).fetchone()["c"],
|
|
"sla_avg_pct": 96,
|
|
"channels": channels,
|
|
}
|
|
|
|
|
|
def list_events(
|
|
conn: sqlite3.Connection,
|
|
*,
|
|
channel: str | None = None,
|
|
priority: str | None = None,
|
|
status: str | None = None,
|
|
q: str | None = None,
|
|
limit: int = 128,
|
|
) -> list[dict]:
|
|
sql = "SELECT * FROM ops_inbox_events WHERE 1=1"
|
|
params: list[Any] = []
|
|
if channel and channel != "all":
|
|
sql += " AND channel = ?"
|
|
params.append(channel)
|
|
if priority:
|
|
sql += " AND priority = ?"
|
|
params.append(priority)
|
|
if status:
|
|
sql += " AND status = ?"
|
|
params.append(status)
|
|
if q:
|
|
sql += " AND (title LIKE ? OR preview LIKE ? OR contact_name LIKE ?)"
|
|
like = f"%{q}%"
|
|
params.extend([like, like, like])
|
|
sql += " ORDER BY datetime(created_at) DESC LIMIT ?"
|
|
params.append(limit)
|
|
rows = conn.execute(sql, params).fetchall()
|
|
out = []
|
|
for row in rows:
|
|
item = dict(row)
|
|
try:
|
|
item["tags"] = json.loads(item.pop("tags_json") or "[]")
|
|
except json.JSONDecodeError:
|
|
item["tags"] = []
|
|
out.append(item)
|
|
return out
|
|
|
|
|
|
def get_event(conn: sqlite3.Connection, event_id: str) -> dict | None:
|
|
row = conn.execute("SELECT * FROM ops_inbox_events WHERE id = ?", (event_id,)).fetchone()
|
|
if not row:
|
|
return None
|
|
item = dict(row)
|
|
item["tags"] = json.loads(item.pop("tags_json") or "[]")
|
|
msgs = conn.execute(
|
|
"""
|
|
SELECT id, author_type, author_label, body, created_at
|
|
FROM ops_inbox_messages WHERE event_id = ? ORDER BY id ASC
|
|
""",
|
|
(event_id,),
|
|
).fetchall()
|
|
item["messages"] = [dict(m) for m in msgs]
|
|
return item
|
|
|
|
|
|
def add_message(conn: sqlite3.Connection, event_id: str, author_type: str, author_label: str, body: str) -> dict:
|
|
now = _now()
|
|
cur = conn.execute(
|
|
"""
|
|
INSERT INTO ops_inbox_messages (event_id, author_type, author_label, body, created_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
""",
|
|
(event_id, author_type, author_label, body.strip(), now),
|
|
)
|
|
conn.execute(
|
|
"UPDATE ops_inbox_events SET updated_at = ? WHERE id = ?",
|
|
(now, event_id),
|
|
)
|
|
return {"id": cur.lastrowid, "event_id": event_id, "author_type": author_type,
|
|
"author_label": author_label, "body": body.strip(), "created_at": now}
|
|
|
|
|
|
def patch_event(conn: sqlite3.Connection, event_id: str, **fields: Any) -> dict | None:
|
|
allowed = {"status", "assignee", "priority"}
|
|
updates = []
|
|
params: list[Any] = []
|
|
for k, v in fields.items():
|
|
if k in allowed and v is not None:
|
|
updates.append(f"{k} = ?")
|
|
params.append(v)
|
|
if not updates:
|
|
ev = get_event(conn, event_id)
|
|
return ev
|
|
updates.append("updated_at = ?")
|
|
params.append(_now())
|
|
params.append(event_id)
|
|
conn.execute(
|
|
f"UPDATE ops_inbox_events SET {', '.join(updates)} WHERE id = ?",
|
|
params,
|
|
)
|
|
return get_event(conn, event_id)
|