from __future__ import annotations from datetime import datetime, timezone import json from fastapi import Depends, FastAPI, HTTPException, Query from sqlalchemy import func, or_, select from services.shared.core import Role, new_id, utc_now_iso from services.shared.db import get_session from services.shared.event_bus import append_outbox_event, event_bus_enabled from services.shared.models import ( AssignRequest, EscalateRequest, HealthResponse, InteractionCreate, InteractionDrilldownFiltersOut, InteractionDrilldownOut, InteractionTimelineAppendIn, InteractionOut, ReportingInteractionFactIn, StatusRequest, ) from services.shared.reporting_facts import upsert_reporting_interaction_fact from services.shared.security import require_roles from services.shared.sql_init import init_sql_schema from services.shared.sql_models import Interaction, InteractionTimeline app = FastAPI(title="interaction-service", version="1.0.0") init_sql_schema() _DRILLDOWN_SORT_COLUMNS = { "created_at": Interaction.created_at, "updated_at": Interaction.updated_at, "subject": Interaction.subject, } _DRILLDOWN_SORT_DIRECTIONS = {"asc", "desc"} def _to_out(row: Interaction) -> InteractionOut: return InteractionOut( interaction_id=row.interaction_id, channel=row.channel, subject=row.subject, customer_id=row.customer_id, queue_id=row.queue_id, priority=row.priority, status=row.status, assigned_to=row.assigned_to, created_at=row.created_at, updated_at=row.updated_at, ) def _push_event(session, interaction_id: str, action: str, metadata: dict | None = None) -> None: session.add( InteractionTimeline( interaction_id=interaction_id, timestamp=utc_now_iso(), action=action, metadata_json=json.dumps(metadata or {}, ensure_ascii=False), ) ) def _reporting_fact_payload( row: Interaction, *, source: str, closed_at: str | None = None, resolved_first_contact: bool | None = None, ) -> dict: return ReportingInteractionFactIn( interaction_id=row.interaction_id, channel=row.channel, # type: ignore[arg-type] queue_id=row.queue_id, agent_id=row.assigned_to, status=row.status, # type: ignore[arg-type] created_at=row.created_at, closed_at=closed_at, resolved_first_contact=resolved_first_contact, source=source, ).model_dump(exclude_none=True) def _upsert_reporting_fact( session, row: Interaction, *, source: str, closed_at: str | None = None, resolved_first_contact: bool | None = None, ) -> None: upsert_reporting_interaction_fact( session, _reporting_fact_payload( row, source=source, closed_at=closed_at, resolved_first_contact=resolved_first_contact, ), ) def _parse_filter_timestamp(raw: str, field_name: str) -> datetime: normalized = raw.strip() if normalized.endswith("Z"): normalized = f"{normalized[:-1]}+00:00" try: value = datetime.fromisoformat(normalized) except ValueError as exc: raise HTTPException(status_code=400, detail=f"Invalid {field_name}") from exc if value.tzinfo is None: value = value.replace(tzinfo=timezone.utc) return value.astimezone(timezone.utc) def _normalize_drilldown_sort(sort_by: str | None, sort_dir: str | None) -> tuple[str, str]: normalized_sort_by = (sort_by or "created_at").strip() or "created_at" normalized_sort_dir = (sort_dir or "desc").strip().lower() or "desc" if normalized_sort_by not in _DRILLDOWN_SORT_COLUMNS: raise HTTPException(status_code=400, detail="Invalid sort_by") if normalized_sort_dir not in _DRILLDOWN_SORT_DIRECTIONS: raise HTTPException(status_code=400, detail="Invalid sort_dir") return normalized_sort_by, normalized_sort_dir def _drilldown_order_by(sort_by: str, sort_dir: str): column = _DRILLDOWN_SORT_COLUMNS[sort_by] direction = column.asc() if sort_dir == "asc" else column.desc() id_order = Interaction.id.asc() if sort_dir == "asc" else Interaction.id.desc() if sort_by == "subject": created_order = Interaction.created_at.asc() if sort_dir == "asc" else Interaction.created_at.desc() return [direction, created_order, id_order] return [direction, id_order] @app.get("/health", response_model=HealthResponse) def health() -> HealthResponse: return HealthResponse(status="ok", service="interaction-service") @app.post("/interactions", response_model=InteractionOut) def create_interaction( payload: InteractionCreate, _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), ) -> InteractionOut: session = get_session() try: now = utc_now_iso() row = Interaction( interaction_id=new_id("int"), channel=payload.channel, subject=payload.subject, customer_id=payload.customer_id, queue_id=payload.queue_id, priority=payload.priority, status="new", assigned_to=None, created_at=now, updated_at=now, ) session.add(row) _push_event(session, row.interaction_id, "interaction.created", {"channel": payload.channel}) _upsert_reporting_fact(session, row, source="interaction-service") if event_bus_enabled(): append_outbox_event( session, event_type="interaction.created", producer_service="interaction-service", entity_type="interaction", entity_id=row.interaction_id, payload={ "event": "interaction.created", "interaction_id": row.interaction_id, "channel": payload.channel, "customer_id": payload.customer_id, "queue_id": payload.queue_id, "priority": payload.priority, "status": "new", "created_at": now, }, ) session.commit() session.refresh(row) return _to_out(row) finally: session.close() @app.get("/interactions/drilldown", response_model=InteractionDrilldownOut) def drilldown_interactions( from_ts: str = Query(...), to_ts: str = Query(...), queue_id: str | None = None, channel: str | None = None, agent_id: str | None = None, status: str | None = None, q: str | None = None, sort_by: str = Query(default="created_at"), sort_dir: str = Query(default="desc"), limit: int = Query(default=25, ge=1, le=100), offset: int = Query(default=0, ge=0), _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)), ) -> InteractionDrilldownOut: range_from = _parse_filter_timestamp(from_ts, "from_ts") range_to = _parse_filter_timestamp(to_ts, "to_ts") if range_to <= range_from: raise HTTPException(status_code=400, detail="to_ts must be greater than from_ts") normalized_q = (q or "").strip() normalized_sort_by, normalized_sort_dir = _normalize_drilldown_sort(sort_by, sort_dir) filters = [ Interaction.created_at >= range_from.isoformat(), Interaction.created_at < range_to.isoformat(), ] if queue_id: filters.append(Interaction.queue_id == queue_id) if channel: filters.append(Interaction.channel == channel) if agent_id: filters.append(Interaction.assigned_to == agent_id) if status: filters.append(Interaction.status == status) if normalized_q: search_pattern = f"%{normalized_q.lower()}%" filters.append( or_( func.lower(func.coalesce(Interaction.interaction_id, "")).like(search_pattern), func.lower(func.coalesce(Interaction.subject, "")).like(search_pattern), func.lower(func.coalesce(Interaction.assigned_to, "")).like(search_pattern), ) ) session = get_session() try: total = session.execute(select(func.count()).select_from(Interaction).where(*filters)).scalar_one() rows = session.execute( select(Interaction) .where(*filters) .order_by(*_drilldown_order_by(normalized_sort_by, normalized_sort_dir)) .offset(offset) .limit(limit) ).scalars().all() return InteractionDrilldownOut( items=[_to_out(row) for row in rows], total=int(total or 0), limit=limit, offset=offset, filters=InteractionDrilldownFiltersOut( from_ts=range_from.isoformat(), to_ts=range_to.isoformat(), queue_id=queue_id, channel=channel, agent_id=agent_id, status=status, q=normalized_q or None, sort_by=normalized_sort_by, sort_dir=normalized_sort_dir, ), ) finally: session.close() @app.get("/interactions/{interaction_id}", response_model=InteractionOut) def get_interaction(interaction_id: str) -> InteractionOut: session = get_session() try: row = session.execute( select(Interaction).where(Interaction.interaction_id == interaction_id) ).scalar_one_or_none() if not row: raise HTTPException(status_code=404, detail="Interaction not found") return _to_out(row) finally: session.close() @app.get("/interactions", response_model=list[InteractionOut]) def list_interactions( status: str | None = None, queue_id: str | None = None, assigned_to: str | None = None, limit: int = 100, ) -> list[InteractionOut]: session = get_session() try: stmt = select(Interaction).order_by(Interaction.id.desc()) if status: stmt = stmt.where(Interaction.status == status) if queue_id: stmt = stmt.where(Interaction.queue_id == queue_id) if assigned_to: stmt = stmt.where(Interaction.assigned_to == assigned_to) rows = session.execute(stmt).scalars().all() return [_to_out(r) for r in rows[:limit]] finally: session.close() @app.patch("/interactions/{interaction_id}/assign", response_model=InteractionOut) def assign_interaction( interaction_id: str, payload: AssignRequest, _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), ) -> InteractionOut: session = get_session() try: row = session.execute( select(Interaction).where(Interaction.interaction_id == interaction_id) ).scalar_one_or_none() if not row: raise HTTPException(status_code=404, detail="Interaction not found") row.assigned_to = payload.assignee row.status = "in_progress" row.updated_at = utc_now_iso() _push_event(session, row.interaction_id, "interaction.assigned", {"assignee": payload.assignee}) _upsert_reporting_fact(session, row, source="interaction-service") if event_bus_enabled(): append_outbox_event( session, event_type="interaction.assigned", producer_service="interaction-service", entity_type="interaction", entity_id=row.interaction_id, payload={ "event": "interaction.assigned", "interaction_id": row.interaction_id, "assignee": payload.assignee, "queue_id": row.queue_id, "channel": row.channel, "status": row.status, "updated_at": row.updated_at, }, ) session.commit() return _to_out(row) finally: session.close() @app.patch("/interactions/{interaction_id}/status", response_model=InteractionOut) def update_status( interaction_id: str, payload: StatusRequest, _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), ) -> InteractionOut: session = get_session() try: row = session.execute( select(Interaction).where(Interaction.interaction_id == interaction_id) ).scalar_one_or_none() if not row: raise HTTPException(status_code=404, detail="Interaction not found") row.status = payload.status row.updated_at = utc_now_iso() metadata = {"status": payload.status} if payload.resolved_first_contact is not None: metadata["resolved_first_contact"] = payload.resolved_first_contact _push_event(session, row.interaction_id, "interaction.status_changed", metadata) _upsert_reporting_fact( session, row, source="interaction-service", closed_at=row.updated_at if payload.status == "closed" else None, resolved_first_contact=payload.resolved_first_contact, ) if payload.status == "closed" and event_bus_enabled(): append_outbox_event( session, event_type="interaction.closed", producer_service="interaction-service", entity_type="interaction", entity_id=row.interaction_id, payload={ "event": "interaction.closed", "interaction_id": row.interaction_id, "queue_id": row.queue_id, "channel": row.channel, "assignee": row.assigned_to, "status": row.status, "resolved_first_contact": payload.resolved_first_contact, "updated_at": row.updated_at, }, ) session.commit() return _to_out(row) finally: session.close() @app.post("/interactions/{interaction_id}/escalate", response_model=InteractionOut) def escalate( interaction_id: str, payload: EscalateRequest, _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), ) -> InteractionOut: session = get_session() try: row = session.execute( select(Interaction).where(Interaction.interaction_id == interaction_id) ).scalar_one_or_none() if not row: raise HTTPException(status_code=404, detail="Interaction not found") row.queue_id = payload.target_queue_id row.status = "escalated" row.updated_at = utc_now_iso() _push_event( session, row.interaction_id, "interaction.escalated", {"target_queue_id": payload.target_queue_id}, ) _upsert_reporting_fact(session, row, source="interaction-service") if event_bus_enabled(): append_outbox_event( session, event_type="interaction.escalated", producer_service="interaction-service", entity_type="interaction", entity_id=row.interaction_id, payload={ "event": "interaction.escalated", "interaction_id": row.interaction_id, "target_queue_id": payload.target_queue_id, "queue_id": row.queue_id, "channel": row.channel, "status": row.status, "updated_at": row.updated_at, }, ) session.commit() return _to_out(row) finally: session.close() @app.get("/interactions/{interaction_id}/timeline") def timeline(interaction_id: str) -> dict: session = get_session() try: row = session.execute( select(Interaction).where(Interaction.interaction_id == interaction_id) ).scalar_one_or_none() if not row: raise HTTPException(status_code=404, detail="Interaction not found") events = session.execute( select(InteractionTimeline) .where(InteractionTimeline.interaction_id == interaction_id) .order_by(InteractionTimeline.id.asc()) ).scalars().all() return { "interaction_id": interaction_id, "events": [ { "timestamp": e.timestamp, "action": e.action, "metadata": json.loads(e.metadata_json or "{}"), } for e in events ], } finally: session.close() @app.post("/interactions/{interaction_id}/timeline") def append_timeline_event( interaction_id: str, payload: InteractionTimelineAppendIn, _: dict = Depends(require_roles(Role.ADMIN)), ) -> dict: session = get_session() try: row = session.execute( select(Interaction).where(Interaction.interaction_id == interaction_id) ).scalar_one_or_none() if not row: raise HTTPException(status_code=404, detail="Interaction not found") row.updated_at = utc_now_iso() _push_event(session, interaction_id, payload.action, payload.metadata) session.commit() return {"ok": True, "interaction_id": interaction_id, "action": payload.action} finally: session.close()