Split into a FastAPI backend and a React frontend, add case state and SSO
Some checks failed
build-and-deploy / test (push) Has been cancelled
build-and-deploy / image (push) Has been cancelled
build-and-deploy / deploy (push) Has been cancelled

The single-file stdlib server became the limit: no way to track what had been
done about an alert, no accounts, and a UI that had to be hand-rolled in
template strings. This restructures it into something deployable.

Backend (FastAPI)
- app/ holds config, database, auth, delivery and the routers; triagelib keeps
  the triage engine unchanged, so the validated screening and runbook logic is
  untouched.
- Cases persist per alert fingerprint with a status workflow (investigating,
  customer contacted, escalated to Infra, waiting, remediated, resolved, won't
  fix, false positive), an assignee, notes and an append-only history. An alert
  that stops and re-fires lands back on the same case and counts as a reopen.
- Suppression rules move from a JSON file into the database.

Auth
- Signed session cookies over PBKDF2 local accounts, plus an OIDC flow ready for
  Authentik: users are created on first login and admin follows a group claim.
  Local login can be switched off entirely once SSO is live.

Zendesk and Jira
- Delivery is now implemented, behind three gates: the integration must be
  configured, its feature flag on, and CX_FEATURE_SEND_ENABLED on. A demo
  instance leaves the last off and cannot mail anyone. Both search before
  creating, so re-diagnosing an alert updates one ticket rather than opening
  several, and a rolling daily cap stops a loop mailing everybody.

Deployment
- Multi-stage Dockerfile builds the bundle and serves it from the API origin.
- docker-compose for local and single-host use; Gitea Actions runs the tests,
  builds the image and renders deploy/k8s with envsubst.

Two fixes found while testing: assigning a case returned a null assignee, and
add_event could leave an already-loaded history collection stale.

Known gap: the engine reaches OpenStack via `docker exec <region>-osc`, which
does not work in a pod without the CX-Tools containers alongside it.
docs/DEPLOYMENT.md sets out the three ways to close that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 07:11:28 +01:00
parent a039e0b5fd
commit 1262690276
68 changed files with 3839 additions and 2223 deletions

174
backend/app/delivery.py Normal file
View File

@@ -0,0 +1,174 @@
"""Outbound delivery to Zendesk and Jira.
Three independent gates have to be open before anything leaves this process:
1. the integration is configured (subdomain/email/token present)
2. its feature flag is on - CX_FEATURE_ZENDESK / CX_FEATURE_JIRA
3. sending is globally enabled - CX_FEATURE_SEND_ENABLED
A demo or staging instance simply leaves the third off, and then no combination
of clicks can email a customer. Every send is recorded as a case event before it
is attempted, so an audit trail exists even when the call fails.
"""
from __future__ import annotations
import datetime as dt
from typing import Any, Optional
import httpx
from sqlalchemy.orm import Session
from .config import get_settings
from .models import Case, CaseEvent, CaseStatus, User
from .services import add_event, set_status
settings = get_settings()
class DeliveryError(RuntimeError):
pass
def _guard(kind: str) -> None:
if not settings.feature_send_enabled:
raise DeliveryError(
"Sending is disabled on this instance (CX_FEATURE_SEND_ENABLED is off). "
"The payload is ready but nothing will leave the server."
)
if kind == "zendesk" and not settings.zendesk_ready:
raise DeliveryError("Zendesk is not configured. Set CX_FEATURE_ZENDESK plus "
"CX_ZENDESK_SUBDOMAIN, CX_ZENDESK_EMAIL and CX_ZENDESK_TOKEN.")
if kind == "jira" and not settings.jira_ready:
raise DeliveryError("Jira is not configured. Set CX_FEATURE_JIRA plus "
"CX_JIRA_BASE, CX_JIRA_EMAIL, CX_JIRA_TOKEN and CX_JIRA_PROJECT.")
def sends_today(db: Session) -> int:
since = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=1)
return (db.query(CaseEvent)
.filter(CaseEvent.action.in_(["zendesk_sent", "jira_created"]))
.filter(CaseEvent.created_at >= since).count())
def _check_cap(db: Session) -> None:
used = sends_today(db)
if used >= settings.send_daily_cap:
raise DeliveryError(
f"Daily send cap reached ({used}/{settings.send_daily_cap}). Raise CX_SEND_DAILY_CAP "
"if this is deliberate - the cap exists so a loop cannot mail every customer."
)
# --- Zendesk ----------------------------------------------------------------
async def send_zendesk(db: Session, case: Case, actor: User, *, to: str, subject: str,
body: str, priority: str = "normal",
tags: Optional[list[str]] = None, public: Optional[bool] = None) -> dict[str, Any]:
_guard("zendesk")
_check_cap(db)
if not to.strip():
raise DeliveryError("No recipient address.")
base = f"https://{settings.zendesk_subdomain}.zendesk.com/api/v2"
auth = (f"{settings.zendesk_email}/token", settings.zendesk_token)
external_id = f"cx-triage-{case.fingerprint}"
async with httpx.AsyncClient(timeout=30) as client:
# Search first so a re-diagnosed alert comments on the existing ticket
# instead of opening a second one for the same customer.
found = await client.get(f"{base}/search.json",
params={"query": f'type:ticket external_id:"{external_id}"'}, auth=auth)
existing = None
if found.status_code == 200:
results = found.json().get("results") or []
existing = results[0] if results else None
comment = {"body": body, "public": settings.zendesk_default_public if public is None else public}
if existing:
resp = await client.put(f"{base}/tickets/{existing['id']}.json",
json={"ticket": {"comment": comment}}, auth=auth)
action = "updated"
else:
payload = {"ticket": {
"subject": subject,
"comment": comment,
"requester": {"name": to.split("@")[0], "email": to},
"priority": priority,
"type": "incident",
"tags": tags or ["cx-triage", f"alert-{case.kind}"],
"external_id": external_id,
}}
resp = await client.post(f"{base}/tickets.json", json=payload, auth=auth)
action = "created"
if resp.status_code not in (200, 201):
add_event(db, case, actor, "zendesk_failed", f"HTTP {resp.status_code}: {resp.text[:300]}")
db.commit()
raise DeliveryError(f"Zendesk returned {resp.status_code}: {resp.text[:300]}")
ticket = resp.json().get("ticket") or {}
ticket_id = str(ticket.get("id") or (existing or {}).get("id") or "")
url = f"https://{settings.zendesk_subdomain}.zendesk.com/agent/tickets/{ticket_id}"
case.zendesk_ticket_id = ticket_id
case.zendesk_ticket_url = url
add_event(db, case, actor, "zendesk_sent",
f"Ticket {ticket_id} {action} for {to}",
{"ticket_id": ticket_id, "to": to, "subject": subject, "action": action})
if case.status in (CaseStatus.NEW, CaseStatus.INVESTIGATING):
set_status(db, case, CaseStatus.CUSTOMER_CONTACTED, actor, f"Zendesk ticket {ticket_id}")
db.commit()
return {"ok": True, "ticket_id": ticket_id, "url": url, "action": action}
# --- Jira -------------------------------------------------------------------
async def create_jira(db: Session, case: Case, actor: User, *, summary: str, description: str,
project: str = "", issue_type: str = "",
labels: Optional[list[str]] = None) -> dict[str, Any]:
_guard("jira")
_check_cap(db)
base = settings.jira_base.rstrip("/")
auth = (settings.jira_email, settings.jira_token)
label = f"cx-triage-{case.fingerprint}"
all_labels = sorted(set((labels or []) + ["cx-triage", label]))
async with httpx.AsyncClient(timeout=30) as client:
found = await client.get(f"{base}/rest/api/3/search",
params={"jql": f'labels = "{label}"', "maxResults": 1}, auth=auth)
if found.status_code == 200 and (found.json().get("issues") or []):
issue = found.json()["issues"][0]
key = issue["key"]
url = f"{base}/browse/{key}"
case.jira_issue_key, case.jira_issue_url = key, url
add_event(db, case, actor, "jira_exists", f"Issue {key} already exists for this case")
db.commit()
return {"ok": True, "key": key, "url": url, "action": "existing"}
payload = {"fields": {
"project": {"key": project or settings.jira_project},
"summary": summary[:250],
"issuetype": {"name": issue_type or settings.jira_issue_type},
"labels": all_labels,
"description": {
"type": "doc", "version": 1,
"content": [{"type": "paragraph",
"content": [{"type": "text", "text": description[:30000]}]}],
},
}}
resp = await client.post(f"{base}/rest/api/3/issue", json=payload, auth=auth)
if resp.status_code not in (200, 201):
add_event(db, case, actor, "jira_failed", f"HTTP {resp.status_code}: {resp.text[:300]}")
db.commit()
raise DeliveryError(f"Jira returned {resp.status_code}: {resp.text[:300]}")
key = resp.json().get("key", "")
url = f"{base}/browse/{key}"
case.jira_issue_key, case.jira_issue_url = key, url
add_event(db, case, actor, "jira_created", f"Issue {key} created", {"key": key, "summary": summary})
if case.status in (CaseStatus.NEW, CaseStatus.INVESTIGATING):
set_status(db, case, CaseStatus.ESCALATED_INFRA, actor, f"Jira {key}")
db.commit()
return {"ok": True, "key": key, "url": url, "action": "created"}