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

109 lines
3.4 KiB
Python

"""Lightweight operator audit helpers.
v4.8 records sensitive/manual operator actions without adding a new table.
The durable audit stream is stored in business_events with event_type
``operator_action`` and, when a task_id is present, mirrored to task_events.
"""
from __future__ import annotations
import json
from typing import Any, Dict, Optional
from sqlalchemy import text
from app.db import engine
def _json(value: Any) -> str:
return json.dumps(value or {}, ensure_ascii=False)
def record_operator_action(
*,
action: str,
entity_type: str,
entity_id: Optional[str] = None,
actor: str = "operator",
task_id: Optional[str] = None,
opportunity_id: Optional[str] = None,
conversation_id: Optional[str] = None,
contact_id: Optional[str] = None,
before: Optional[Dict[str, Any]] = None,
after: Optional[Dict[str, Any]] = None,
payload: Optional[Dict[str, Any]] = None,
) -> None:
"""Record a manual/sensitive operator action.
Audit SQL invariant: event_type = 'operator_action'.
This intentionally avoids a schema migration. ``business_events.task_id`` is
nullable, so it can hold system/outbox/admin events as a generic audit log.
"""
action = str(action or "operator_action").strip() or "operator_action"
entity_type = str(entity_type or "unknown").strip() or "unknown"
audit_payload = {
"action": action,
"entity_type": entity_type,
"entity_id": str(entity_id or task_id or opportunity_id or ""),
"actor": str(actor or "operator"),
"opportunity_id": opportunity_id,
"before": before or {},
"after": after or {},
**(payload or {}),
}
task_uuid = task_id if task_id else None
with engine.begin() as conn:
conn.execute(text("""
INSERT INTO business_events (
event_type,
task_id,
conversation_id,
contact_id,
payload,
created_by
)
VALUES (
'operator_action',
CAST(:task_id AS UUID),
:conversation_id,
:contact_id,
CAST(:payload AS JSONB),
:created_by
)
"""), {
"task_id": task_uuid,
"conversation_id": conversation_id,
"contact_id": contact_id,
"payload": _json(audit_payload),
"created_by": str(actor or "operator"),
})
if task_id:
conn.execute(text("""
INSERT INTO task_events (
task_id,
event_type,
payload,
created_by
)
VALUES (
CAST(:task_id AS UUID),
'operator_action',
CAST(:payload AS JSONB),
:created_by
)
"""), {
"task_id": task_id,
"payload": _json(audit_payload),
"created_by": str(actor or "operator"),
})
def record_operator_action_best_effort(**kwargs: Any) -> None:
"""Audit helper that must never break the operational action itself."""
try:
record_operator_action(**kwargs)
except Exception as exc: # pragma: no cover - defensive logging only
print(f"ClientFlow operator audit failed: {exc}", flush=True)