"""Incident service implementing incident lifecycle operations.""" from __future__ import annotations from datetime import datetime from typing import cast from uuid import UUID, uuid4 import asyncpg from asyncpg.pool import PoolConnectionProxy from app.api.deps import CurrentUser, ensure_org_access from app.core import exceptions as exc from app.db import Database, db from app.repositories import IncidentRepository, ServiceRepository from app.schemas.common import PaginatedResponse from app.schemas.incident import ( CommentRequest, IncidentCreate, IncidentEventResponse, IncidentResponse, TransitionRequest, ) _ALLOWED_TRANSITIONS: dict[str, set[str]] = { "triggered": {"acknowledged"}, "acknowledged": {"mitigated"}, "mitigated": {"resolved"}, "resolved": set(), } def _as_conn(conn: asyncpg.Connection | PoolConnectionProxy) -> asyncpg.Connection: """Helper to satisfy typing when a pool proxy is returned.""" return cast(asyncpg.Connection, conn) class IncidentService: """Encapsulates incident lifecycle operations within an org context.""" def __init__(self, database: Database | None = None) -> None: self.db = database or db async def create_incident( self, current_user: CurrentUser, service_id: UUID, data: IncidentCreate, ) -> IncidentResponse: """Create an incident for a service in the active org and record the creation event.""" async with self.db.transaction() as conn: db_conn = _as_conn(conn) service_repo = ServiceRepository(db_conn) incident_repo = IncidentRepository(db_conn) service = await service_repo.get_by_id(service_id) if service is None: raise exc.NotFoundError("Service not found") ensure_org_access(service["org_id"], current_user) incident_id = uuid4() incident = await incident_repo.create( incident_id=incident_id, org_id=current_user.org_id, service_id=service_id, title=data.title, description=data.description, severity=data.severity, ) await incident_repo.add_event( uuid4(), incident_id, "created", actor_user_id=current_user.user_id, payload={ "title": data.title, "severity": data.severity, "description": data.description, }, ) return IncidentResponse(**incident) async def get_incidents( self, current_user: CurrentUser, *, status: str | None = None, cursor: datetime | None = None, limit: int = 20, ) -> PaginatedResponse[IncidentResponse]: """Return paginated incidents for the active organization.""" async with self.db.connection() as conn: incident_repo = IncidentRepository(_as_conn(conn)) rows = await incident_repo.get_by_org( org_id=current_user.org_id, status=status, cursor=cursor, limit=limit, ) has_more = len(rows) > limit items = rows[:limit] next_cursor = items[-1]["created_at"].isoformat() if has_more and items else None incidents = [IncidentResponse(**row) for row in items] return PaginatedResponse[IncidentResponse]( items=incidents, next_cursor=next_cursor, has_more=has_more, ) async def get_incident(self, current_user: CurrentUser, incident_id: UUID) -> IncidentResponse: """Return a single incident, ensuring it belongs to the active org.""" async with self.db.connection() as conn: incident_repo = IncidentRepository(_as_conn(conn)) incident = await incident_repo.get_by_id(incident_id) if incident is None: raise exc.NotFoundError("Incident not found") ensure_org_access(incident["org_id"], current_user) return IncidentResponse(**incident) async def get_incident_events( self, current_user: CurrentUser, incident_id: UUID ) -> list[IncidentEventResponse]: """Return the timeline events for an incident in the active org.""" async with self.db.connection() as conn: incident_repo = IncidentRepository(_as_conn(conn)) incident = await incident_repo.get_by_id(incident_id) if incident is None: raise exc.NotFoundError("Incident not found") ensure_org_access(incident["org_id"], current_user) events = await incident_repo.get_events(incident_id) return [IncidentEventResponse(**event) for event in events] async def transition_incident( self, current_user: CurrentUser, incident_id: UUID, data: TransitionRequest, ) -> IncidentResponse: """Transition an incident status with optimistic locking and event recording.""" async with self.db.transaction() as conn: db_conn = _as_conn(conn) incident_repo = IncidentRepository(db_conn) incident = await incident_repo.get_by_id(incident_id) if incident is None: raise exc.NotFoundError("Incident not found") ensure_org_access(incident["org_id"], current_user) self._validate_transition(incident["status"], data.to_status) updated = await incident_repo.update_status( incident_id, data.to_status, data.version, ) if updated is None: raise exc.ConflictError("Incident version mismatch") payload = {"from": incident["status"], "to": data.to_status} if data.note: payload["note"] = data.note await incident_repo.add_event( uuid4(), incident_id, "status_changed", actor_user_id=current_user.user_id, payload=payload, ) return IncidentResponse(**updated) async def add_comment( self, current_user: CurrentUser, incident_id: UUID, data: CommentRequest, ) -> IncidentEventResponse: """Add a comment event to the incident timeline.""" async with self.db.connection() as conn: incident_repo = IncidentRepository(_as_conn(conn)) incident = await incident_repo.get_by_id(incident_id) if incident is None: raise exc.NotFoundError("Incident not found") ensure_org_access(incident["org_id"], current_user) event = await incident_repo.add_event( uuid4(), incident_id, "comment_added", actor_user_id=current_user.user_id, payload={"content": data.content}, ) return IncidentEventResponse(**event) def _validate_transition(self, current_status: str, to_status: str) -> None: """Validate a requested status transition against the allowed state machine.""" if current_status == to_status: raise exc.BadRequestError("Incident is already in the requested status") allowed = _ALLOWED_TRANSITIONS.get(current_status, set()) if to_status not in allowed: raise exc.BadRequestError("Invalid incident status transition") __all__ = ["IncidentService"]