Specs stay at repo root (cross-VM). Move deploy and code into logical projects with README per domain, updated manifest.yaml, and symlinks at legacy paths for VM122 backward compatibility.
554 lines
22 KiB
Python
554 lines
22 KiB
Python
"""Assist / takeover API — Spec 010 Phase A."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import secrets
|
|
from datetime import datetime, timezone
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
from app import assist_catalog, assist_store, auth
|
|
from app.permissions import (
|
|
can_assist_handoff,
|
|
can_assist_takeover,
|
|
can_read_assist,
|
|
)
|
|
|
|
router = APIRouter(prefix="/api/v1/assist", tags=["assist"])
|
|
|
|
VM112_ASSIST_API = os.getenv("VM112_ASSIST_API_URL", os.getenv("VM112_API_URL", "http://10.10.10.112:8090"))
|
|
VM112_ASSIST_TOKEN = os.getenv("VM112_ASSIST_SERVICE_TOKEN", "")
|
|
DESK_ASSIST_ENABLED = os.getenv("DESK_ASSIST_ENABLED", "true").lower() in ("1", "true", "yes")
|
|
VM112_ASSIST_CALL = os.getenv("VM112_ASSIST_CALL_VM112", "false").lower() in ("1", "true", "yes")
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def _main():
|
|
from app import main as m
|
|
|
|
return m
|
|
|
|
|
|
def _session_meta(conn, session_id: str) -> dict:
|
|
m = _main()
|
|
meta = assist_store.session_funnel_meta(
|
|
conn, session_id, m.FUNNEL_EVENT_RANK, m.FUNNEL_STAGE_BY_RANK, m.ONBOARD_SOURCE
|
|
)
|
|
if not meta.get("session_id"):
|
|
raise HTTPException(404, "sessão não encontrada no funil")
|
|
if meta["funnel_rank"] == 0 and not meta.get("last_event_at"):
|
|
raise HTTPException(404, "sessão não encontrada no funil")
|
|
return meta
|
|
|
|
|
|
def _parse_payload(raw: str | None) -> dict:
|
|
m = _main()
|
|
return m._parse_payload(raw)
|
|
|
|
|
|
def _build_session_view(conn, session_id: str, user: auth.DeskUser) -> dict:
|
|
m = _main()
|
|
meta = _session_meta(conn, session_id)
|
|
assist = assist_store.get_active_assist(conn, session_id)
|
|
ticket_row = assist_store.find_ticket_by_session(conn, session_id)
|
|
ticket = None
|
|
if ticket_row:
|
|
full = conn.execute(
|
|
f"SELECT {m.TICKET_COLUMNS}, session_id, assist_mode, assisted_by, assisted_at, client_paused FROM tickets WHERE id = ?",
|
|
(int(ticket_row["id"]),),
|
|
).fetchone()
|
|
if full:
|
|
ticket = m._visible_ticket(m._enrich_ticket(full), user)
|
|
view = {
|
|
**meta,
|
|
"assist_status": assist["status"] if assist else None,
|
|
"assist_session_id": assist["id"] if assist else None,
|
|
"assisted_by": assist.get("initiated_by_user") if assist else (ticket or {}).get("assisted_by"),
|
|
"ticket_id": int(ticket_row["id"]) if ticket_row else None,
|
|
"ticket_status": ticket_row["status"] if ticket_row else None,
|
|
"assigned_to": ticket_row["assigned_to"] if ticket_row else None,
|
|
"can_takeover": meta["can_escalate"] and can_assist_takeover(user.role) and not (assist and assist.get("status") == "active"),
|
|
}
|
|
if ticket:
|
|
view["ticket"] = ticket
|
|
return view
|
|
|
|
|
|
def _call_vm112(
|
|
method: str,
|
|
path: str,
|
|
json_body: dict | None = None,
|
|
session_id: str | None = None,
|
|
) -> dict | None:
|
|
if not VM112_ASSIST_CALL:
|
|
return None
|
|
headers: dict[str, str] = {}
|
|
if VM112_ASSIST_TOKEN:
|
|
headers["X-Desk-Assist-Token"] = VM112_ASSIST_TOKEN
|
|
if session_id:
|
|
headers["X-Onboarding-Session"] = session_id
|
|
try:
|
|
with httpx.Client(timeout=30.0) as client:
|
|
r = client.request(method, f"{VM112_ASSIST_API.rstrip('/')}{path}", json=json_body, headers=headers)
|
|
if r.status_code >= 400:
|
|
return {"error": r.text[:200], "http_status": r.status_code}
|
|
return r.json() if r.headers.get("content-type", "").startswith("application/json") else {"ok": True}
|
|
except Exception as exc:
|
|
return {"error": str(exc)}
|
|
|
|
|
|
@router.get("/sessions")
|
|
def list_assist_sessions(user: auth.DeskUser = Depends(auth.get_current_user)):
|
|
if not DESK_ASSIST_ENABLED:
|
|
raise HTTPException(503, "assistência desactivada")
|
|
if not can_read_assist(user.role):
|
|
raise HTTPException(403, "permissão insuficiente")
|
|
m = _main()
|
|
with m.db() as conn:
|
|
funnel = m._funnel_summary(conn, window_hours=48)
|
|
session_ids = [s["session_id"] for s in funnel.get("active_sessions", []) if s.get("session_id")]
|
|
assist_map = assist_store.get_assist_state_map(conn, session_ids)
|
|
sessions = []
|
|
for item in funnel.get("active_sessions", []):
|
|
sid = item.get("session_id")
|
|
if not sid:
|
|
continue
|
|
assist = assist_map.get(sid)
|
|
ticket_row = assist_store.find_ticket_by_session(conn, sid)
|
|
status = "observing"
|
|
if assist and assist.get("status") == "active":
|
|
status = "assisting"
|
|
elif ticket_row and ticket_row["status"] in ("escalated", "assisting"):
|
|
status = ticket_row["status"]
|
|
meta = assist_store.session_funnel_meta(
|
|
conn, sid, m.FUNNEL_EVENT_RANK, m.FUNNEL_STAGE_BY_RANK, m.ONBOARD_SOURCE
|
|
)
|
|
sessions.append({
|
|
**item,
|
|
"assist_status": status,
|
|
"assisted_by": assist.get("initiated_by_user") if assist else None,
|
|
"assigned_to": ticket_row["assigned_to"] if ticket_row else None,
|
|
"can_escalate": meta.get("can_escalate", False),
|
|
})
|
|
return {"sessions": sessions, "window_hours": funnel.get("window_hours", 48)}
|
|
|
|
|
|
@router.get("/sessions/{session_id}")
|
|
def get_assist_session(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
|
|
if not can_read_assist(user.role):
|
|
raise HTTPException(403, "permissão insuficiente")
|
|
sid = session_id.strip()
|
|
m = _main()
|
|
with m.db() as conn:
|
|
view = _build_session_view(conn, sid, user)
|
|
timeline = m._session_timeline(conn, sid)
|
|
from app.funnel_timing import apply_module_timing
|
|
|
|
enriched, timing_meta = apply_module_timing(timeline)
|
|
view["timeline"] = enriched
|
|
if timing_meta:
|
|
view["timing"] = timing_meta
|
|
actions = []
|
|
if view.get("assist_session_id"):
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT actor, action, payload, created_at
|
|
FROM assist_actions WHERE assist_session_id = ?
|
|
ORDER BY id ASC LIMIT 50
|
|
""",
|
|
(view["assist_session_id"],),
|
|
).fetchall()
|
|
actions = [
|
|
{
|
|
"actor": r["actor"],
|
|
"action": r["action"],
|
|
"payload": _parse_payload(r["payload"]),
|
|
"created_at": r["created_at"],
|
|
}
|
|
for r in rows
|
|
]
|
|
view["actions"] = actions
|
|
return view
|
|
|
|
|
|
@router.post("/sessions/{session_id}/escalate")
|
|
def escalate_session(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
|
|
if not can_assist_takeover(user.role):
|
|
raise HTTPException(403, "permissão insuficiente")
|
|
sid = session_id.strip()
|
|
now = _now()
|
|
m = _main()
|
|
with m.db() as conn:
|
|
meta = _session_meta(conn, sid)
|
|
if not meta["can_escalate"]:
|
|
raise HTTPException(400, "escalada só após validação de domínio")
|
|
active = assist_store.get_active_assist(conn, sid)
|
|
open_assist = assist_store.get_open_assist(conn, sid)
|
|
if active:
|
|
raise HTTPException(
|
|
409,
|
|
detail={
|
|
"message": "sessão já em assistência",
|
|
"assisted_by": active.get("initiated_by_user"),
|
|
},
|
|
)
|
|
if open_assist and open_assist.get("status") == "escalated" and open_assist.get("initiated_by_user") not in (None, user.username):
|
|
raise HTTPException(
|
|
409,
|
|
detail={
|
|
"message": "sessão já escalada por outro técnico",
|
|
"assisted_by": open_assist.get("initiated_by_user"),
|
|
},
|
|
)
|
|
ticket_id = assist_store.ensure_onboard_ticket(conn, sid, meta.get("domain"))
|
|
conn.execute(
|
|
"""
|
|
UPDATE tickets
|
|
SET status = 'escalated', assigned_to = ?, assigned_at = ?, session_id = ?
|
|
WHERE id = ?
|
|
""",
|
|
(user.username, now, sid, ticket_id),
|
|
)
|
|
if open_assist and open_assist.get("status") == "escalated":
|
|
assist_id = int(open_assist["id"])
|
|
conn.execute(
|
|
"UPDATE assist_sessions SET initiated_by_user = ?, initiated_by = 'technician' WHERE id = ?",
|
|
(user.username, assist_id),
|
|
)
|
|
else:
|
|
cur = conn.execute(
|
|
"""
|
|
INSERT INTO assist_sessions
|
|
(session_id, ticket_id, initiated_by, initiated_by_user, status, funnel_stage, domain, started_at)
|
|
VALUES (?, ?, 'technician', ?, 'escalated', ?, ?, ?)
|
|
""",
|
|
(sid, ticket_id, user.username, meta.get("funnel_stage"), meta.get("domain"), now),
|
|
)
|
|
assist_id = int(cur.lastrowid)
|
|
assist_store.log_action(conn, assist_id, user.username, "escalate", {"source": "desk"})
|
|
conn.commit()
|
|
view = _build_session_view(conn, sid, user)
|
|
return {"status": "escalated", "ticket_id": ticket_id, "session": view}
|
|
|
|
|
|
@router.post("/sessions/{session_id}/takeover")
|
|
def takeover_session(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
|
|
if not can_assist_takeover(user.role):
|
|
raise HTTPException(403, "permissão insuficiente")
|
|
sid = session_id.strip()
|
|
now = _now()
|
|
m = _main()
|
|
with m.db() as conn:
|
|
meta = _session_meta(conn, sid)
|
|
if not meta["can_escalate"]:
|
|
raise HTTPException(400, "takeover só após validação de domínio")
|
|
active = assist_store.get_active_assist(conn, sid)
|
|
open_assist = assist_store.get_open_assist(conn, sid)
|
|
if active and active.get("initiated_by_user") not in (None, user.username):
|
|
raise HTTPException(
|
|
409,
|
|
detail={
|
|
"message": "sessão já assumida por outro técnico",
|
|
"assisted_by": active.get("initiated_by_user"),
|
|
},
|
|
)
|
|
ticket_id = assist_store.ensure_onboard_ticket(conn, sid, meta.get("domain"))
|
|
token_hash = assist_store.hash_token(secrets.token_urlsafe(32))
|
|
|
|
if open_assist and open_assist.get("status") == "escalated":
|
|
assist_id = int(open_assist["id"])
|
|
conn.execute(
|
|
"""
|
|
UPDATE assist_sessions
|
|
SET status = 'active', initiated_by_user = ?, takeover_token_hash = ?
|
|
WHERE id = ?
|
|
""",
|
|
(user.username, token_hash, assist_id),
|
|
)
|
|
elif active:
|
|
assist_id = int(active["id"])
|
|
else:
|
|
cur = conn.execute(
|
|
"""
|
|
INSERT INTO assist_sessions
|
|
(session_id, ticket_id, initiated_by, initiated_by_user, status, funnel_stage, domain,
|
|
takeover_token_hash, started_at)
|
|
VALUES (?, ?, 'technician', ?, 'active', ?, ?, ?, ?)
|
|
""",
|
|
(sid, ticket_id, user.username, meta.get("funnel_stage"), meta.get("domain"), token_hash, now),
|
|
)
|
|
assist_id = int(cur.lastrowid)
|
|
|
|
conn.execute(
|
|
"""
|
|
UPDATE tickets
|
|
SET status = 'assisting', assigned_to = ?, assigned_at = ?, session_id = ?,
|
|
assist_mode = 'asm', assisted_by = ?, assisted_at = ?, client_paused = 1
|
|
WHERE id = ?
|
|
""",
|
|
(user.username, now, sid, user.username, now, ticket_id),
|
|
)
|
|
assist_store.log_action(conn, assist_id, user.username, "takeover", {"phase": "A"})
|
|
conn.commit()
|
|
|
|
_call_vm112("POST", f"/onboarding/sessions/{sid}/pause", session_id=sid)
|
|
vm112_result = _call_vm112(
|
|
"POST",
|
|
f"/onboarding/sessions/{sid}/takeover",
|
|
{"technician": user.username},
|
|
session_id=sid,
|
|
)
|
|
if vm112_result and vm112_result.get("takeover_url"):
|
|
takeover_url = vm112_result["takeover_url"]
|
|
else:
|
|
base = os.getenv("VM112_WIZARD_URL", "https://onboard.ibytera.com")
|
|
takeover_url = f"{base.rstrip('/')}/assist/{sid}?desk=1"
|
|
|
|
return {
|
|
"status": "assisting",
|
|
"ticket_id": ticket_id,
|
|
"takeover_url": takeover_url,
|
|
"client_paused": True,
|
|
"vm112": vm112_result,
|
|
"note": "ASM activo — wizard VM112 pausado para o cliente",
|
|
}
|
|
|
|
|
|
@router.post("/sessions/{session_id}/handoff")
|
|
def handoff_session(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
|
|
if not can_assist_handoff(user.role, user.username):
|
|
raise HTTPException(403, "permissão insuficiente")
|
|
sid = session_id.strip()
|
|
now = _now()
|
|
m = _main()
|
|
with m.db() as conn:
|
|
active = assist_store.get_active_assist(conn, sid)
|
|
if not active:
|
|
raise HTTPException(404, "nenhuma assistência activa")
|
|
if user.role == "technician" and active.get("initiated_by_user") not in (None, user.username):
|
|
raise HTTPException(403, "sessão de outro técnico")
|
|
ticket_row = assist_store.find_ticket_by_session(conn, sid)
|
|
ticket_id = int(ticket_row["id"]) if ticket_row else active.get("ticket_id")
|
|
conn.execute(
|
|
"UPDATE assist_sessions SET status = 'ended', ended_at = ? WHERE id = ?",
|
|
(now, int(active["id"])),
|
|
)
|
|
if ticket_id:
|
|
conn.execute(
|
|
"""
|
|
UPDATE tickets
|
|
SET status = 'resolved', client_paused = 0, assist_mode = NULL
|
|
WHERE id = ?
|
|
""",
|
|
(ticket_id,),
|
|
)
|
|
assist_store.log_action(conn, int(active["id"]), user.username, "handoff", {})
|
|
conn.commit()
|
|
_call_vm112("POST", f"/onboarding/sessions/{sid}/resume", session_id=sid)
|
|
return {"status": "handoff", "ticket_id": ticket_id}
|
|
|
|
|
|
@router.get("/sessions/{session_id}/links")
|
|
def session_external_links(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
|
|
if not can_read_assist(user.role):
|
|
raise HTTPException(403, "permissão insuficiente")
|
|
sid = session_id.strip()
|
|
m = _main()
|
|
with m.db() as conn:
|
|
meta = _session_meta(conn, sid)
|
|
return {
|
|
"session_id": sid,
|
|
"domain": meta.get("domain"),
|
|
"links": assist_catalog.external_links(meta.get("domain")),
|
|
}
|
|
|
|
|
|
@router.get("/sessions/{session_id}/actions")
|
|
def list_session_actions(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
|
|
if not can_read_assist(user.role):
|
|
raise HTTPException(403, "permissão insuficiente")
|
|
sid = session_id.strip()
|
|
m = _main()
|
|
with m.db() as conn:
|
|
meta = _session_meta(conn, sid)
|
|
assist = assist_store.get_active_assist(conn, sid)
|
|
ticket_row = assist_store.find_ticket_by_session(conn, sid)
|
|
is_assisting = bool(assist and assist.get("status") == "active") or (
|
|
ticket_row and ticket_row["status"] == "assisting"
|
|
)
|
|
actions = assist_catalog.list_actions_for_session(
|
|
meta.get("funnel_stage"), is_assisting, user.role
|
|
)
|
|
return {"session_id": sid, "actions": actions, "is_assisting": is_assisting}
|
|
|
|
|
|
@router.post("/sessions/{session_id}/actions/{action_id}")
|
|
def run_session_action(
|
|
session_id: str,
|
|
action_id: str,
|
|
user: auth.DeskUser = Depends(auth.get_current_user),
|
|
):
|
|
if not can_assist_takeover(user.role):
|
|
raise HTTPException(403, "permissão insuficiente")
|
|
sid = session_id.strip()
|
|
action = action_id.strip()
|
|
now = _now()
|
|
m = _main()
|
|
with m.db() as conn:
|
|
meta = _session_meta(conn, sid)
|
|
domain = (meta.get("domain") or "").strip()
|
|
if not domain:
|
|
raise HTTPException(400, "domínio não disponível na sessão")
|
|
assist = assist_store.get_active_assist(conn, sid)
|
|
open_assist = assist_store.get_open_assist(conn, sid)
|
|
assist_id = int(assist["id"]) if assist else (int(open_assist["id"]) if open_assist else None)
|
|
ticket_row = assist_store.find_ticket_by_session(conn, sid)
|
|
ticket_payload = _parse_payload(ticket_row["payload"]) if ticket_row else {}
|
|
is_assisting = bool(assist and assist.get("status") == "active")
|
|
|
|
if action == assist_catalog.ABORT_ACTION:
|
|
if user.role not in ("super_admin", "ops_lead"):
|
|
raise HTTPException(403, "abortar só ops_lead+")
|
|
if ticket_row:
|
|
conn.execute(
|
|
"UPDATE tickets SET status = 'closed', client_paused = 0, assist_mode = NULL WHERE id = ?",
|
|
(int(ticket_row["id"]),),
|
|
)
|
|
if assist:
|
|
conn.execute(
|
|
"UPDATE assist_sessions SET status = 'ended', ended_at = ? WHERE id = ?",
|
|
(now, int(assist["id"])),
|
|
)
|
|
if assist_id:
|
|
assist_store.log_action(conn, assist_id, user.username, f"action.{action}", {"domain": domain})
|
|
conn.commit()
|
|
_call_vm112("POST", f"/onboarding/sessions/{sid}/resume", session_id=sid)
|
|
return {"status": "aborted", "action": action, "session_id": sid}
|
|
|
|
if action == assist_catalog.MARK_STEP_ACTION:
|
|
if not is_assisting:
|
|
raise HTTPException(400, "acção só em modo assistindo")
|
|
if assist_id:
|
|
assist_store.log_action(conn, assist_id, user.username, f"action.{action}", {"domain": domain})
|
|
conn.commit()
|
|
return {"status": "ok", "action": action, "note": "passo marcado no audit log"}
|
|
|
|
if action not in assist_catalog.DESK_ACTIONS:
|
|
raise HTTPException(400, f"acção inválida: {action}")
|
|
spec = assist_catalog.DESK_ACTIONS[action]
|
|
if assist_catalog.funnel_rank(meta.get("funnel_stage")) < spec["min_rank"]:
|
|
raise HTTPException(400, "etapa do funil insuficiente para esta acção")
|
|
if action not in ("dns.revalidate", "account.retry_sync") and not is_assisting:
|
|
raise HTTPException(400, "assuma a sessão antes desta acção")
|
|
|
|
method, path, body = assist_catalog.build_vm112_request(action, domain, ticket_payload)
|
|
vm112_result = _call_vm112(method, path, body, session_id=sid)
|
|
if not assist_id:
|
|
ticket_id = assist_store.ensure_onboard_ticket(conn, sid, domain)
|
|
cur = conn.execute(
|
|
"""
|
|
INSERT INTO assist_sessions
|
|
(session_id, ticket_id, initiated_by, initiated_by_user, status, funnel_stage, domain, started_at)
|
|
VALUES (?, ?, 'technician', ?, 'escalated', ?, ?, ?)
|
|
""",
|
|
(sid, ticket_id, user.username, meta.get("funnel_stage"), domain, now),
|
|
)
|
|
assist_id = int(cur.lastrowid)
|
|
assist_store.log_action(
|
|
conn,
|
|
assist_id,
|
|
user.username,
|
|
f"action.{action}",
|
|
{"domain": domain, "vm112": vm112_result},
|
|
)
|
|
conn.commit()
|
|
|
|
if vm112_result is None and not VM112_ASSIST_CALL:
|
|
return {
|
|
"status": "logged",
|
|
"action": action,
|
|
"note": "VM112_ASSIST_CALL_VM112=false — acção registada no audit",
|
|
}
|
|
if vm112_result and vm112_result.get("http_status", 0) >= 400:
|
|
raise HTTPException(502, detail={"message": "VM112 rejeitou acção", "vm112": vm112_result})
|
|
return {"status": "ok", "action": action, "vm112": vm112_result}
|
|
|
|
|
|
@router.get("/technicians/ranking")
|
|
def technicians_ranking(
|
|
window_days: int = 30,
|
|
user: auth.DeskUser = Depends(auth.get_current_user),
|
|
):
|
|
if not can_read_assist(user.role):
|
|
raise HTTPException(403, "permissão insuficiente")
|
|
if user.role == "noc":
|
|
raise HTTPException(403, "permissão insuficiente")
|
|
days = max(1, min(window_days, 365))
|
|
m = _main()
|
|
with m.db() as conn:
|
|
ranking = assist_catalog.technician_ranking(conn, window_days=days)
|
|
return {"window_days": days, "ranking": ranking, "total": len(ranking)}
|
|
|
|
|
|
def process_assist_started(conn, body, now: str) -> dict:
|
|
m = _main()
|
|
sid = (body.session_id or "").strip()
|
|
technician = (body.data or {}).get("technician")
|
|
meta = assist_store.session_funnel_meta(
|
|
conn, sid, m.FUNNEL_EVENT_RANK, m.FUNNEL_STAGE_BY_RANK, m.ONBOARD_SOURCE
|
|
)
|
|
ticket_id = assist_store.ensure_onboard_ticket(conn, sid, body.domain or meta.get("domain"))
|
|
conn.execute(
|
|
"""
|
|
UPDATE tickets
|
|
SET status = 'assisting', session_id = ?, assist_mode = 'asm',
|
|
assisted_by = ?, assisted_at = ?, client_paused = 1
|
|
WHERE id = ?
|
|
""",
|
|
(sid, technician, now, ticket_id),
|
|
)
|
|
return {"handled": True, "ticket_id": ticket_id, "session_id": sid}
|
|
|
|
|
|
def process_assist_ended(conn, body, now: str) -> dict:
|
|
sid = (body.session_id or "").strip()
|
|
ticket_row = assist_store.find_ticket_by_session(conn, sid)
|
|
if ticket_row:
|
|
conn.execute(
|
|
"UPDATE tickets SET status = 'resolved', client_paused = 0, assist_mode = NULL WHERE id = ?",
|
|
(int(ticket_row["id"]),),
|
|
)
|
|
return {"handled": True, "ticket_id": int(ticket_row["id"]), "session_id": sid}
|
|
return {"handled": False, "session_id": sid}
|
|
|
|
|
|
def process_escalation_webhook(conn, body, now: str) -> dict:
|
|
"""Handle onboarding.escalated / onboarding.failed from VM112."""
|
|
m = _main()
|
|
sid = (body.session_id or "").strip()
|
|
if not sid:
|
|
return {"handled": False, "reason": "missing session_id"}
|
|
meta = assist_store.session_funnel_meta(
|
|
conn, sid, m.FUNNEL_EVENT_RANK, m.FUNNEL_STAGE_BY_RANK, m.ONBOARD_SOURCE
|
|
)
|
|
ticket_id = assist_store.ensure_onboard_ticket(conn, sid, body.domain or meta.get("domain"))
|
|
conn.execute(
|
|
"UPDATE tickets SET status = 'escalated', session_id = ? WHERE id = ?",
|
|
(sid, ticket_id),
|
|
)
|
|
if not assist_store.get_open_assist(conn, sid):
|
|
cur = conn.execute(
|
|
"""
|
|
INSERT INTO assist_sessions
|
|
(session_id, ticket_id, initiated_by, initiated_by_user, status, funnel_stage, domain, started_at)
|
|
VALUES (?, ?, 'client', NULL, 'escalated', ?, ?, ?)
|
|
""",
|
|
(sid, ticket_id, meta.get("funnel_stage"), body.domain or meta.get("domain"), now),
|
|
)
|
|
assist_store.log_action(conn, int(cur.lastrowid), "client", "escalate", {"event": body.event})
|
|
return {"handled": True, "ticket_id": ticket_id, "session_id": sid}
|