diff --git a/backend/app/api/schemas/reports.py b/backend/app/api/schemas/reports.py new file mode 100644 index 0000000..618de6a --- /dev/null +++ b/backend/app/api/schemas/reports.py @@ -0,0 +1,227 @@ +""" +Reports Schemas - ServiceManagerWeb + +Schemas de respuesta para el módulo de reportes y estadísticas. +""" + +from pydantic import BaseModel, ConfigDict +from typing import Optional, List, Dict, Any +from datetime import datetime + + +# =================================== +# RESUMEN GENERAL +# =================================== + +class TicketsByStatus(BaseModel): + """Conteo de tickets agrupado por estado""" + new: int = 0 + triage: int = 0 + in_progress: int = 0 + waiting_customer: int = 0 + resolved: int = 0 + closed: int = 0 + reopened: int = 0 + total: int = 0 + + +class TicketsByPriority(BaseModel): + """Conteo de tickets agrupado por prioridad""" + low: int = 0 + medium: int = 0 + high: int = 0 + urgent: int = 0 + total: int = 0 + + +class ReportSummaryResponse(BaseModel): + """Resumen ejecutivo del período seleccionado""" + period_start: datetime + period_end: datetime + generated_at: datetime + + # Totales del período + total_tickets: int + open_tickets: int # Tickets sin resolver + resolved_tickets: int # Tickets resueltos o cerrados + avg_resolution_hours: Optional[float] # Promedio de horas para resolver + avg_first_response_hours: Optional[float] # Promedio de horas para primera respuesta + + # Satisfacción del cliente + avg_rating: Optional[float] # Promedio de calificación (1-5) + total_rated: int # Cuántos tickets tienen calificación + + # Desglose por estado y prioridad + by_status: TicketsByStatus + by_priority: TicketsByPriority + + # Comparación vs período anterior + tickets_change_pct: Optional[float] # % cambio vs período anterior + resolution_change_pct: Optional[float] # % cambio en tasa de resolución + + model_config = ConfigDict(from_attributes=True) + + +# =================================== +# RENDIMIENTO POR AGENTE +# =================================== + +class AgentReportRow(BaseModel): + """Estadísticas de un agente específico""" + agent_id: str + agent_name: str + agent_email: str + total_assigned: int # Total asignados en el período + resolved: int # Cuántos resolvió + open: int # Cuántos siguen abiertos + resolution_rate: float # Porcentaje de resolución (0-100) + avg_resolution_hours: Optional[float] # Promedio de horas para resolver + avg_rating: Optional[float] # Calificación promedio (1-5) + total_rated: int # Cuántos tickets calificaron al agente + urgent_handled: int # Urgentes atendidos + + +class AgentReportResponse(BaseModel): + """Reporte de rendimiento por agente""" + period_start: datetime + period_end: datetime + generated_at: datetime + agents: List[AgentReportRow] + total_agents: int + + model_config = ConfigDict(from_attributes=True) + + +# =================================== +# TICKETS POR CATEGORÍA +# =================================== + +class CategoryReportRow(BaseModel): + """Estadísticas de una categoría""" + category_id: str + category_name: str + total_tickets: int + open_tickets: int + resolved_tickets: int + avg_resolution_hours: Optional[float] + sla_response_hours: int # SLA configurado para respuesta + sla_resolution_hours: int # SLA configurado para resolución + sla_compliance_pct: float # % de tickets que cumplieron SLA de resolución + + +class CategoryReportResponse(BaseModel): + """Reporte de tickets agrupado por categoría""" + period_start: datetime + period_end: datetime + generated_at: datetime + categories: List[CategoryReportRow] + uncategorized_count: int + + model_config = ConfigDict(from_attributes=True) + + +# =================================== +# TICKETS POR CLIENTE (TENANT) +# =================================== + +class ClientReportRow(BaseModel): + """Estadísticas de un cliente (tenant)""" + tenant_id: str + tenant_name: str + total_tickets: int + open_tickets: int + resolved_tickets: int + urgent_tickets: int + avg_resolution_hours: Optional[float] + avg_rating: Optional[float] + last_ticket_at: Optional[datetime] + + +class ClientReportResponse(BaseModel): + """Reporte de tickets agrupado por cliente — solo ADMIN""" + period_start: datetime + period_end: datetime + generated_at: datetime + clients: List[ClientReportRow] + total_clients: int + + model_config = ConfigDict(from_attributes=True) + + +# =================================== +# TENDENCIAS (TICKETS EN EL TIEMPO) +# =================================== + +class TrendDataPoint(BaseModel): + """Un punto de datos en la línea de tendencia""" + date: str # Formato YYYY-MM-DD + created: int # Tickets creados ese día + resolved: int # Tickets resueltos ese día + net_open: int # Diferencia: creados - resueltos + + +class TrendsReportResponse(BaseModel): + """Evolución de tickets día a día""" + period_start: datetime + period_end: datetime + generated_at: datetime + data_points: List[TrendDataPoint] + total_days: int + + model_config = ConfigDict(from_attributes=True) + + +# =================================== +# SATISFACCIÓN DEL CLIENTE (CSAT) +# =================================== + +class CSATDistribution(BaseModel): + """Distribución de calificaciones 1-5""" + rating_1: int = 0 + rating_2: int = 0 + rating_3: int = 0 + rating_4: int = 0 + rating_5: int = 0 + + +class CSATReportResponse(BaseModel): + """Reporte de satisfacción del cliente""" + period_start: datetime + period_end: datetime + generated_at: datetime + avg_rating: Optional[float] + total_rated: int + total_tickets: int + response_rate: float # % de tickets que recibieron calificación + distribution: CSATDistribution + by_category: List[Dict[str, Any]] # Promedio por categoría + by_agent: List[Dict[str, Any]] # Promedio por agente + recent_comments: List[Dict[str, Any]] = [] # Últimos comentarios de calificación + + model_config = ConfigDict(from_attributes=True) + + +# =================================== +# TICKETS POR SISTEMA AFECTADO +# =================================== + +class SystemReportRow(BaseModel): + """Estadísticas de un sistema afectado""" + system_id: str + system_name: str + total_tickets: int + open_tickets: int + resolved_tickets: int + urgent_tickets: int + avg_resolution_hours: Optional[float] + + +class SystemReportResponse(BaseModel): + """Reporte de tickets agrupado por sistema afectado""" + period_start: datetime + period_end: datetime + generated_at: datetime + systems: List[SystemReportRow] + no_system_count: int # Tickets sin sistema asignado + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/api/v1/audit_helpers.py b/backend/app/api/v1/audit_helpers.py index 5d71ae9..1580b37 100644 --- a/backend/app/api/v1/audit_helpers.py +++ b/backend/app/api/v1/audit_helpers.py @@ -1,8 +1,34 @@ -"""Helper functions for audit endpoints""" +""" +Audit Helpers - ServiceManagerWeb +=================================== +Funciones auxiliares reutilizables para los endpoints de auditoría. + +Este archivo contiene: + - audit_log_to_dict: Convierte un modelo AuditLog a diccionario + - apply_tenant_filter: Aplica filtro de tenant según permisos + - get_count_stat: Cuenta registros con filtros opcionales (CORREGIDO) + - get_top_items: Obtiene los items más frecuentes + - detect_mass_deletions: Detecta eliminaciones masivas sospechosas + - detect_brute_force: Detecta ataques de fuerza bruta + - detect_privilege_escalation: Detecta escaladas de privilegios + +CORRECCIÓN APLICADA en get_count_stat: + La columna created_at en PostgreSQL es 'timestamp with time zone' (TIMESTAMPTZ), + lo que significa que almacena y devuelve fechas CON información de timezone (+00). + + El bug era que se comparaba un datetime naive (sin timezone) contra una columna + TIMESTAMPTZ. PostgreSQL no puede comparar ambos tipos directamente, por lo que + el filtro se ignoraba silenciosamente y los tres contadores devolvían el mismo + valor (el total histórico completo sin ningún filtro de fecha). + + La solución es garantizar que TODAS las fechas que se usen en queries tengan + timezone info (aware datetime en UTC) usando _ensure_aware_utc(). +""" + from sqlalchemy import select, func, and_, or_, desc from sqlalchemy.ext.asyncio import AsyncSession from typing import Optional, Dict, List -from datetime import datetime +from datetime import datetime, timezone import uuid from app.models.audit import AuditLog @@ -10,8 +36,18 @@ from app.models.user import User, UserRole from app.models.tenant import Tenant +# ============================================================================= +# CONVERSIÓN DE MODELOS +# ============================================================================= + def audit_log_to_dict(log: AuditLog) -> dict: - """Convierte AuditLog a diccionario de respuesta""" + """ + Convierte un objeto AuditLog de SQLAlchemy a un diccionario plano + compatible con los schemas de respuesta de Pydantic. + + Incluye los datos del usuario relacionado si están cargados + (requiere que la query use selectinload(AuditLog.user)). + """ log_dict = { "id": log.id, "tenant_id": log.tenant_id, @@ -19,203 +55,463 @@ def audit_log_to_dict(log: AuditLog) -> dict: "action": log.action, "resource_type": log.resource_type, "resource_id": log.resource_id, + # ip_address puede ser un objeto especial de PostgreSQL, convertir a string "ip_address": str(log.ip_address) if log.ip_address else None, "user_agent": log.user_agent, "correlation_id": log.correlation_id, "old_values": log.old_values, "new_values": log.new_values, + # extra_metadata evita conflicto con la palabra reservada 'metadata' "metadata": log.extra_metadata, "created_at": log.created_at, "action_display": log.action_display, + # Campos del usuario (se llenan abajo si la relación está cargada) "user_email": None, - "user_name": None + "user_name": None, + "user_role": None, } - + + # Solo agregar datos del usuario si la relación fue cargada en la query if log.user: log_dict["user_email"] = log.user.email log_dict["user_name"] = log.user.full_name - log_dict["user_role"] = log.user.role.value if hasattr(log.user.role, 'value') else str(log.user.role) - + # El rol puede ser un Enum de Python o un string, manejar ambos casos + log_dict["user_role"] = ( + log.user.role.value + if hasattr(log.user.role, 'value') + else str(log.user.role) + ) + return log_dict -def apply_tenant_filter(query, current_user: User, current_tenant: Tenant, all_tenants: bool = False, specific_tenant_id: Optional[uuid.UUID] = None): - """Aplica filtro de tenant según permisos del usuario""" +# ============================================================================= +# FILTRO DE MULTI-TENANCY +# ============================================================================= + +def apply_tenant_filter( + query, + current_user: User, + current_tenant: Tenant, + all_tenants: bool = False, + specific_tenant_id: Optional[uuid.UUID] = None +): + """ + Aplica el filtro de tenant a una query de SQLAlchemy según los + permisos del usuario actual. + + Reglas: + - ADMIN y SUPPORT_MANAGER pueden ver todos los tenants si + all_tenants=True, o filtrar por un tenant específico. + - Cualquier otro rol solo puede ver los datos de su propio tenant. + """ can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER] - + if all_tenants and can_see_all_tenants: - return query # No filtrar por tenant + # Usuario privilegiado pidiendo ver todos los tenants → sin filtro + return query elif specific_tenant_id and can_see_all_tenants: + # Usuario privilegiado pidiendo un tenant específico return query.where(AuditLog.tenant_id == specific_tenant_id) else: + # Cualquier otro caso → solo ver el propio tenant return query.where(AuditLog.tenant_id == current_tenant.id) -async def get_count_stat(db: AsyncSession, tenant_id: Optional[uuid.UUID] = None, - date_from: Optional[datetime] = None, action_filter=None) -> int: - """Obtiene estadística de conteo con filtros opcionales""" +# ============================================================================= +# UTILIDAD DE FECHAS +# ============================================================================= + +def _ensure_aware_utc(dt: datetime) -> datetime: + """ + Garantiza que un datetime tenga información de timezone en UTC. + + PROBLEMA QUE RESUELVE: + La columna created_at en PostgreSQL es 'timestamp with time zone' + (TIMESTAMPTZ). Cuando se compara con un datetime naive (sin timezone), + PostgreSQL no puede hacer la comparación correctamente y el filtro + de fecha se ignora silenciosamente, devolviendo todos los registros + sin importar la fecha. + + SOLUCIÓN: + Siempre convertir las fechas a aware UTC antes de usarlas en queries. + + Casos que maneja: + - datetime naive (sin tzinfo): agrega UTC como timezone + - datetime aware (con tzinfo): convierte a UTC si es otra zona horaria + + Ejemplos: + datetime(2026, 2, 24, 15, 0, 0) → datetime(2026, 2, 24, 15, 0, 0, tzinfo=UTC) + datetime(2026, 2, 24, 9, 0, 0, tzinfo=CST) → datetime(2026, 2, 24, 15, 0, 0, tzinfo=UTC) + """ + if dt.tzinfo is None: + # Datetime naive → asumir que ya es UTC y agregarle timezone info + return dt.replace(tzinfo=timezone.utc) + else: + # Datetime aware → convertir a UTC (por si viene en otra zona horaria) + return dt.astimezone(timezone.utc) + + +# ============================================================================= +# CONTADORES DE ESTADÍSTICAS +# ============================================================================= + +async def get_count_stat( + db: AsyncSession, + tenant_id: Optional[uuid.UUID] = None, + date_from: Optional[datetime] = None, + action_filter=None +) -> int: + """ + Cuenta registros de AuditLog con filtros opcionales. + + Usado por get_audit_stats() para calcular: + - total_actions: Sin date_from → cuenta todos los registros + - actions_today: date_from = now - 24h → registros del día + - actions_this_week: date_from = now - 7d → registros de la semana + + CORRECCIÓN: Las fechas se convierten a aware UTC con _ensure_aware_utc() + antes de usarlas en la query, para que sean compatibles con la columna + TIMESTAMPTZ de PostgreSQL y el filtro se aplique correctamente. + + Args: + db: Sesión de base de datos + tenant_id: Si se especifica, filtra por ese tenant + date_from: Si se especifica, solo cuenta registros desde esa fecha + action_filter: Condición SQLAlchemy adicional opcional + + Returns: + Número entero de registros que cumplen los filtros + """ query = select(func.count()).select_from(AuditLog) - + if tenant_id: query = query.where(AuditLog.tenant_id == tenant_id) + if date_from: - query = query.where(AuditLog.created_at >= date_from) + # CORRECCIÓN: convertir a aware UTC para compatibilidad con TIMESTAMPTZ + # Sin esto, el filtro se ignora y los tres contadores son idénticos + date_from_aware = _ensure_aware_utc(date_from) + query = query.where(AuditLog.created_at >= date_from_aware) + if action_filter is not None: query = query.where(action_filter) - + result = await db.execute(query) return result.scalar() or 0 -async def get_top_items(db: AsyncSession, field, tenant_id: Optional[uuid.UUID] = None, - limit: int = 5, join_user: bool = False) -> Dict[str, int]: - """Obtiene top items por campo con conteo""" +# ============================================================================= +# ITEMS MÁS FRECUENTES +# ============================================================================= + +async def get_top_items( + db: AsyncSession, + field, + tenant_id: Optional[uuid.UUID] = None, + limit: int = 5, + join_user: bool = False +) -> Dict[str, int]: + """ + Obtiene los valores más frecuentes de un campo, ordenados por conteo. + + Ejemplos de uso: + - get_top_items(db, AuditLog.action, ...) → {"ticket.create": 45} + - get_top_items(db, AuditLog.resource_type, ...) → {"ticket": 60} + - get_top_items(db, None, ..., join_user=True) → {"admin@empresa.com": 40} + + Args: + db: Sesión de base de datos + field: Campo de AuditLog por el que agrupar + tenant_id: Si se especifica, filtra por ese tenant + limit: Máximo de resultados a devolver (por defecto 5) + join_user: Si True, agrupa por email de usuario + + Returns: + Diccionario {valor: conteo} ordenado de mayor a menor + """ if join_user: - query = select(User.email, func.count(AuditLog.id).label('count')).join(User, AuditLog.user_id == User.id) + # Modo usuarios: hacer JOIN con tabla User y agrupar por email + query = ( + select(User.email, func.count(AuditLog.id).label('count')) + .join(User, AuditLog.user_id == User.id) + ) else: + # Modo campo: agrupar por el campo especificado query = select(field, func.count(AuditLog.id).label('count')) - + if tenant_id: query = query.where(AuditLog.tenant_id == tenant_id) - - if not join_user: - query = query.group_by(field) - else: + + if join_user: query = query.group_by(User.email) - + else: + query = query.group_by(field) + query = query.order_by(desc('count')).limit(limit) - + result = await db.execute(query) return {row[0]: row[1] for row in result} +# ============================================================================= +# DETECTORES DE INCIDENTES DE SEGURIDAD +# ============================================================================= + def detect_mass_deletions(logs: List[AuditLog], now: datetime) -> List[dict]: - """Detecta eliminaciones masivas de logs de auditoría""" + """ + Detecta patrones de eliminación masiva agrupando por usuario y día. + + Lógica: + - Agrupa todos los logs de eliminación por (usuario, día) + - Si un usuario eliminó >= 3 recursos en un día, genera un incidente + - La severidad escala según la cantidad: + - >= 3 eliminaciones → medium + - >= 5 eliminaciones → high + - >= 10 eliminaciones → critical + + El estado del incidente es: + - "active": si la última eliminación fue hace menos de 24 horas + - "resolved": si fue hace más de 24 horas + """ + # Agrupar eliminaciones por usuario y día deletion_groups = {} - + for log in logs: if not log.user: continue - + key = f"{log.user.email}_{log.created_at.date()}" + if key not in deletion_groups: deletion_groups[key] = { - 'user': log.user.email, 'date': log.created_at.date(), - 'count': 0, 'logs': [], 'first_seen': log.created_at, 'last_seen': log.created_at + 'user': log.user.email, + 'date': log.created_at.date(), + 'count': 0, + 'logs': [], + 'first_seen': log.created_at, + 'last_seen': log.created_at } - + deletion_groups[key]['count'] += 1 deletion_groups[key]['logs'].append(log) deletion_groups[key]['first_seen'] = min(deletion_groups[key]['first_seen'], log.created_at) deletion_groups[key]['last_seen'] = max(deletion_groups[key]['last_seen'], log.created_at) - + incidents = [] + for key, group in deletion_groups.items(): - if group['count'] >= 3: - severity = "critical" if group['count'] >= 10 else "high" if group['count'] >= 5 else "medium" - status = "active" if (now - group['last_seen']).days <= 1 else "resolved" - - incidents.append({ - "id": f"mass_del_{key.replace('_', '-')}", - "title": f"Eliminaciones masivas - {group['user']}", - "description": f"{group['user']} eliminó {group['count']} elementos el {group['date']}", - "severity": severity, - "status": status, - "incident_type": "mass_deletion", - "affected_user": group['user'], - "source_ip": str(group['logs'][0].ip_address) if group['logs'][0].ip_address else None, - "evidence": [f"{log.action} - {log.resource_type} - {log.created_at.strftime('%H:%M:%S')}" for log in group['logs'][:5]], - "metadata": { - "total_deletions": group['count'], - "resource_types": list(set(log.resource_type for log in group['logs'])), - "time_span_minutes": int((group['last_seen'] - group['first_seen']).total_seconds() / 60) - }, - "created_at": group['first_seen'], - "updated_at": group['last_seen'] - }) - + if group['count'] < 3: + continue + + if group['count'] >= 10: + severity = "critical" + elif group['count'] >= 5: + severity = "high" + else: + severity = "medium" + + # Convertir ambas fechas a aware UTC para comparación segura + now_aware = _ensure_aware_utc(now) + last_seen_aware = _ensure_aware_utc(group['last_seen']) + hours_since_last = (now_aware - last_seen_aware).total_seconds() / 3600 + incident_status = "active" if hours_since_last <= 24 else "resolved" + + incidents.append({ + "id": f"mass_del_{key.replace('_', '-')}", + "title": f"Eliminaciones masivas - {group['user']}", + "description": ( + f"{group['user']} elimino {group['count']} elementos " + f"el {group['date']}" + ), + "severity": severity, + "status": incident_status, + "incident_type": "mass_deletion", + "affected_user": group['user'], + "source_ip": ( + str(group['logs'][0].ip_address) + if group['logs'][0].ip_address + else None + ), + "evidence": [ + f"{log.action} - {log.resource_type} - {log.created_at.strftime('%H:%M:%S')}" + for log in group['logs'][:5] + ], + "metadata": { + "total_deletions": group['count'], + "resource_types": list(set(log.resource_type for log in group['logs'])), + "time_span_minutes": int( + (group['last_seen'] - group['first_seen']).total_seconds() / 60 + ) + }, + "created_at": group['first_seen'], + "updated_at": group['last_seen'] + }) + return incidents def detect_brute_force(logs: List[AuditLog], now: datetime) -> List[dict]: - """Detecta ataques de fuerza bruta de logs de login fallido""" + """ + Detecta ataques de fuerza bruta agrupando intentos fallidos por IP. + + Lógica: + - Agrupa todos los intentos fallidos de login por dirección IP + - Si una IP tiene >= 5 intentos, genera un incidente + - La severidad escala según la cantidad: + - >= 5 intentos → medium + - >= 10 intentos → high + - >= 20 intentos → critical + + El estado del incidente es: + - "active": si el último intento fue hace menos de 24 horas + - "investigating": si fue hace más de 24 horas + """ ip_groups = {} - + for log in logs: if not log.ip_address: continue - + ip = str(log.ip_address) + if ip not in ip_groups: - ip_groups[ip] = {'count': 0, 'logs': [], 'first_seen': log.created_at, 'last_seen': log.created_at, 'users': set()} - + ip_groups[ip] = { + 'count': 0, + 'logs': [], + 'first_seen': log.created_at, + 'last_seen': log.created_at, + 'users': set() + } + ip_groups[ip]['count'] += 1 ip_groups[ip]['logs'].append(log) ip_groups[ip]['first_seen'] = min(ip_groups[ip]['first_seen'], log.created_at) ip_groups[ip]['last_seen'] = max(ip_groups[ip]['last_seen'], log.created_at) + if log.user and log.user.email: ip_groups[ip]['users'].add(log.user.email) - + incidents = [] + for ip, group in ip_groups.items(): - if group['count'] >= 5: - severity = "critical" if group['count'] >= 20 else "high" if group['count'] >= 10 else "medium" - status = "active" if (now - group['last_seen']).total_seconds() <= 86400 else "investigating" - - incidents.append({ - "id": f"brute_force_{ip.replace('.', '-')}", - "title": f"Posible ataque de fuerza bruta desde {ip}", - "description": f"Se detectaron {group['count']} intentos fallidos de login desde la IP {ip}", - "severity": severity, - "status": status, - "incident_type": "brute_force_attack", - "affected_user": ', '.join(list(group['users'])[:3]) if group['users'] else None, - "source_ip": ip, - "evidence": [f"Login fallido - {log.user.email if log.user else 'Unknown'} - {log.created_at.strftime('%H:%M:%S')}" for log in group['logs'][:5]], - "metadata": { - "total_attempts": group['count'], - "targeted_users": list(group['users']), - "time_span_hours": int((group['last_seen'] - group['first_seen']).total_seconds() / 3600) - }, - "created_at": group['first_seen'], - "updated_at": group['last_seen'] - }) - + if group['count'] < 5: + continue + + if group['count'] >= 20: + severity = "critical" + elif group['count'] >= 10: + severity = "high" + else: + severity = "medium" + + # Convertir ambas fechas a aware UTC para comparación segura + now_aware = _ensure_aware_utc(now) + last_seen_aware = _ensure_aware_utc(group['last_seen']) + seconds_since_last = (now_aware - last_seen_aware).total_seconds() + incident_status = "active" if seconds_since_last <= 86400 else "investigating" + + incidents.append({ + "id": f"brute_force_{ip.replace('.', '-')}", + "title": f"Posible ataque de fuerza bruta desde {ip}", + "description": ( + f"Se detectaron {group['count']} intentos fallidos de " + f"login desde la IP {ip}" + ), + "severity": severity, + "status": incident_status, + "incident_type": "brute_force_attack", + "affected_user": ( + ', '.join(list(group['users'])[:3]) + if group['users'] + else None + ), + "source_ip": ip, + "evidence": [ + f"Login fallido - " + f"{log.user.email if log.user else 'Desconocido'} - " + f"{log.created_at.strftime('%H:%M:%S')}" + for log in group['logs'][:5] + ], + "metadata": { + "total_attempts": group['count'], + "targeted_users": list(group['users']), + "time_span_hours": int( + (group['last_seen'] - group['first_seen']).total_seconds() / 3600 + ) + }, + "created_at": group['first_seen'], + "updated_at": group['last_seen'] + }) + return incidents def detect_privilege_escalation(logs: List[AuditLog]) -> List[dict]: - """Detecta escaladas de privilegios""" - role_hierarchy = {'CLIENT_USER': 1, 'CLIENT_ADMIN': 2, 'AGENT': 3, 'SUPPORT_MANAGER': 4, 'ADMIN': 5} + """ + Detecta escaladas de privilegios comparando el rol anterior y nuevo. + + Lógica: + - Analiza cada log de cambio de rol (user.update con campo 'role') + - Si el nuevo rol tiene más privilegios que el anterior, es sospechoso + - Cada cambio que represente una escalada genera un incidente + + Jerarquía de roles (de menor a mayor privilegio): + CLIENT_USER(1) < CLIENT_ADMIN(2) < AGENT(3) < SUPPORT_MANAGER(4) < ADMIN(5) + """ + role_hierarchy = { + 'CLIENT_USER': 1, + 'CLIENT_ADMIN': 2, + 'AGENT': 3, + 'SUPPORT_MANAGER': 4, + 'ADMIN': 5 + } + incidents = [] - + for log in logs: if not log.user or not log.new_values or 'role' not in log.new_values: continue - + old_role = log.old_values.get('role') if log.old_values else 'Unknown' new_role = log.new_values.get('role') + old_level = role_hierarchy.get(old_role, 0) new_level = role_hierarchy.get(new_role, 0) - - if new_level > old_level: - incidents.append({ - "id": f"priv_esc_{log.id}", - "title": f"Escalada de privilegios - {log.user.email}", - "description": f"Usuario {log.user.email} cambió de rol {old_role} a {new_role}", - "severity": "high" if new_role in ['ADMIN', 'SUPPORT_MANAGER'] else "medium", - "status": "investigating", - "incident_type": "privilege_escalation", - "affected_user": log.user.email, - "source_ip": str(log.ip_address) if log.ip_address else None, - "evidence": [f"Cambio de rol: {old_role} → {new_role} - {log.created_at.strftime('%Y-%m-%d %H:%M')}"], - "metadata": { - "old_role": old_role, - "new_role": new_role, - "correlation_id": str(log.correlation_id) if log.correlation_id else None - }, - "created_at": log.created_at, - "updated_at": log.created_at - }) - - return incidents + + # Solo generar incidente si el nuevo rol tiene MÁS privilegios + if new_level <= old_level: + continue + + severity = "high" if new_role in ['ADMIN', 'SUPPORT_MANAGER'] else "medium" + + incidents.append({ + "id": f"priv_esc_{log.id}", + "title": f"Escalada de privilegios - {log.user.email}", + "description": ( + f"Usuario {log.user.email} cambio de rol " + f"{old_role} a {new_role}" + ), + "severity": severity, + "status": "investigating", + "incident_type": "privilege_escalation", + "affected_user": log.user.email, + "source_ip": str(log.ip_address) if log.ip_address else None, + "evidence": [ + f"Cambio de rol: {old_role} → {new_role} - " + f"{log.created_at.strftime('%Y-%m-%d %H:%M')}" + ], + "metadata": { + "old_role": old_role, + "new_role": new_role, + "correlation_id": ( + str(log.correlation_id) + if log.correlation_id + else None + ) + }, + "created_at": log.created_at, + "updated_at": log.created_at + }) + + return incidents \ No newline at end of file diff --git a/backend/app/api/v1/endpoints/audit.py b/backend/app/api/v1/endpoints/audit.py index 5567c3d..3eac23b 100644 --- a/backend/app/api/v1/endpoints/audit.py +++ b/backend/app/api/v1/endpoints/audit.py @@ -1,4 +1,29 @@ -"""Audit Endpoints - ServiceManagerWeb""" +""" +Audit Endpoints - ServiceManagerWeb +==================================== +Este archivo maneja todos los endpoints de auditoría y seguridad. +Rutas disponibles: + GET /audit/ → Lista de logs con filtros y paginación + GET /audit/stats → Estadísticas generales de auditoría + GET /audit/{log_id} → Detalle de un log específico + GET /audit/security/analysis → Análisis de amenazas en tiempo real + POST /audit/security/action → Ejecutar acción de seguridad (bloquear IP, etc.) + GET /audit/security/incidents → Lista de incidentes detectados + +CORRECCIONES APLICADAS: + 1. Todos los endpoints usan datetime.now(timezone.utc) para generar + fechas aware (con timezone info en UTC), compatibles con la columna + 'timestamp with time zone' (TIMESTAMPTZ) de PostgreSQL. + + 2. audit_helpers.get_count_stat() convierte las fechas a aware UTC + con _ensure_aware_utc() antes de usarlas en queries, resolviendo + el bug donde los tres contadores (total, hoy, semana) devolvían + el mismo valor porque el filtro de fecha se ignoraba. + + 3. critical_actions_today usa los mismos umbrales que /security/incidents + para que el contador del dashboard coincida con la lista de detalles. +""" + from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, and_, or_, desc @@ -24,31 +49,83 @@ from app.api.v1.audit_helpers import ( detect_mass_deletions, detect_brute_force, detect_privilege_escalation ) +# Instancia del router de FastAPI para este módulo router = APIRouter() + +# Logger estructurado para registrar eventos internos del sistema logger = structlog.get_logger(__name__) + +# ============================================================================= +# DEPENDENCIA DE AUTORIZACIÓN +# ============================================================================= + def require_auditor_role(current_user: User = Depends(get_current_user)) -> User: - """Verifica que el usuario tenga rol de auditor""" + """ + Dependencia reutilizable que verifica que el usuario tenga permisos + para ver logs de auditoría. + + Solo pueden acceder los roles: ADMIN, SUPPORT_MANAGER, AUDITOR. + Si no tiene el rol correcto, lanza un error 403 Forbidden. + """ if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR]: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, - detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder a logs de auditoría") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Solo usuarios con rol ADMIN, SUPPORT_MANAGER o AUDITOR pueden acceder a logs de auditoría" + ) return current_user + +# ============================================================================= +# ENDPOINT: LISTA DE LOGS DE AUDITORÍA +# ============================================================================= + @router.get("/", response_model=AuditLogListResponse) -async def get_audit_logs(page: int = Query(default=1, ge=1), per_page: int = Query(default=50, ge=1, le=100), - user_id: Optional[uuid.UUID] = Query(None), action: Optional[str] = Query(None), - resource_type: Optional[str] = Query(None), resource_id: Optional[uuid.UUID] = Query(None), - date_from: Optional[datetime] = Query(None), date_to: Optional[datetime] = Query(None), - search: Optional[str] = Query(None), tenant_id: Optional[uuid.UUID] = Query(None), - all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role), - current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)): - """Obtener logs de auditoría con filtros y paginación""" - logger.info("Fetching audit logs", user_id=str(current_user.id), tenant_id=str(current_tenant.id), - filters={"user_id": str(user_id) if user_id else None, "action": action, "page": page, "all_tenants": all_tenants}) - +async def get_audit_logs( + # Paginación + page: int = Query(default=1, ge=1), + per_page: int = Query(default=50, ge=1, le=100), + # Filtros opcionales + user_id: Optional[uuid.UUID] = Query(None), + action: Optional[str] = Query(None), + resource_type: Optional[str] = Query(None), + resource_id: Optional[uuid.UUID] = Query(None), + date_from: Optional[datetime] = Query(None), + date_to: Optional[datetime] = Query(None), + search: Optional[str] = Query(None), + tenant_id: Optional[uuid.UUID] = Query(None), + all_tenants: bool = Query(False), + # Dependencias de autenticación y base de datos + current_user: User = Depends(require_auditor_role), + current_tenant: Tenant = Depends(get_current_tenant), + db: AsyncSession = Depends(get_db) +): + """ + Obtener el historial completo de logs de auditoría con filtros opcionales. + + Soporta filtrar por usuario, tipo de acción, recurso afectado, fechas + y búsqueda de texto. También soporta ver logs de todos los tenants + si el usuario tiene permisos de ADMIN o SUPPORT_MANAGER. + """ + logger.info( + "Obteniendo logs de auditoria", + user_id=str(current_user.id), + tenant_id=str(current_tenant.id), + filters={ + "user_id": str(user_id) if user_id else None, + "action": action, + "page": page, + "all_tenants": all_tenants + } + ) + + # Construir la query base con relación al usuario que hizo la acción query = select(AuditLog).options(selectinload(AuditLog.user)) + + # Aplicar filtro de tenant según permisos del usuario query = apply_tenant_filter(query, current_user, current_tenant, all_tenants, tenant_id) - + + # Aplicar filtros opcionales uno por uno if user_id: query = query.where(AuditLog.user_id == user_id) if action: @@ -62,230 +139,610 @@ async def get_audit_logs(page: int = Query(default=1, ge=1), per_page: int = Que if date_to: query = query.where(AuditLog.created_at < date_to) if search: + # Búsqueda parcial en el campo "action" (ej: "ticket" encuentra "ticket.create") query = query.where(AuditLog.action.ilike(f"%{search}%")) - + + # Ordenar por fecha descendente (más reciente primero) query = query.order_by(desc(AuditLog.created_at)) - + + # Contar total de registros para calcular páginas count_query = select(func.count()).select_from(query.subquery()) total = (await db.execute(count_query)).scalar() or 0 - + + # Aplicar paginación offset = (page - 1) * per_page query = query.offset(offset).limit(per_page) - + + # Ejecutar query y obtener resultados result = await db.execute(query) logs = result.scalars().all() - + + # Calcular número total de páginas total_pages = (total + per_page - 1) // per_page + + # Convertir modelos a schemas de respuesta logs_response = [AuditLogResponse(**audit_log_to_dict(log)) for log in logs] - - return AuditLogListResponse(logs=logs_response, total=total, page=page, per_page=per_page, total_pages=total_pages) + + return AuditLogListResponse( + logs=logs_response, + total=total, + page=page, + per_page=per_page, + total_pages=total_pages + ) + + +# ============================================================================= +# ENDPOINT: ESTADÍSTICAS DE AUDITORÍA +# ============================================================================= @router.get("/stats", response_model=AuditLogStats) -async def get_audit_stats(all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role), - current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)): - """Obtener estadísticas de auditoría""" +async def get_audit_stats( + all_tenants: bool = Query(False), + current_user: User = Depends(require_auditor_role), + current_tenant: Tenant = Depends(get_current_tenant), + db: AsyncSession = Depends(get_db) +): + """ + Obtener estadísticas resumidas de auditoría para el dashboard. + + Incluye: + - Total de acciones registradas + - Acciones de las últimas 24 horas + - Acciones de los últimos 7 días + - Incidentes críticos detectados hoy (alineado con /security/incidents) + - Acciones más frecuentes + - Usuarios más activos + - Distribución por tipo de recurso + """ can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER] - logger.info("Fetching audit stats", user_id=str(current_user.id), tenant_id=str(current_tenant.id), - all_tenants=all_tenants, can_see_all=can_see_all_tenants) - + + logger.info( + "Obteniendo estadisticas de auditoria", + user_id=str(current_user.id), + tenant_id=str(current_tenant.id), + all_tenants=all_tenants, + can_see_all=can_see_all_tenants + ) + + # datetime.now(timezone.utc) genera un datetime aware en UTC, + # compatible con la columna TIMESTAMPTZ de PostgreSQL now = datetime.now(timezone.utc) + + # Determinar si se debe filtrar por tenant o ver todos apply_tenant = not (all_tenants and can_see_all_tenants) tenant_filter = current_tenant.id if apply_tenant else None - + + # ------------------------------------------------------------------ + # CONTADORES GENERALES + # ------------------------------------------------------------------ + + # Total histórico de acciones (sin filtro de fecha) total_actions = await get_count_stat(db, tenant_filter) + + # Acciones en las últimas 24 horas + # get_count_stat convierte internamente a aware UTC con _ensure_aware_utc() actions_today = await get_count_stat(db, tenant_filter, now - timedelta(days=1)) + + # Acciones en los últimos 7 días actions_this_week = await get_count_stat(db, tenant_filter, now - timedelta(days=7)) - + + # ------------------------------------------------------------------ + # CONTADOR DE INCIDENTES CRÍTICOS + # ------------------------------------------------------------------ + # Usa los mismos umbrales que los detectores de /security/incidents + # para que el número del dashboard sea consistente con la lista. + # ------------------------------------------------------------------ + today_start = now - timedelta(days=1) - critical_conditions = [ - AuditLog.created_at >= today_start, - or_(AuditLog.action.like('%.delete'), AuditLog.action.like('user.update'), - AuditLog.action.like('%.assign'), AuditLog.action.in_(['user.login_failed', 'user.logout'])) - ] - if apply_tenant: - critical_conditions.append(AuditLog.tenant_id == tenant_filter) - - critical_actions_today = (await db.execute(select(func.count()).select_from(AuditLog).where(and_(*critical_conditions)))).scalar() or 0 - + + # Contar intentos fallidos de login en las últimas 24 horas + failed_login_count = (await db.execute( + select(func.count()).select_from(AuditLog).where( + AuditLog.action == 'user.login_failed', + AuditLog.created_at >= today_start, + *([AuditLog.tenant_id == tenant_filter] if apply_tenant else []) + ) + )).scalar() or 0 + + # Contar eliminaciones en las últimas 24 horas + deletion_count = (await db.execute( + select(func.count()).select_from(AuditLog).where( + AuditLog.action.like('%.delete'), + AuditLog.created_at >= today_start, + *([AuditLog.tenant_id == tenant_filter] if apply_tenant else []) + ) + )).scalar() or 0 + + # Contar cambios de privilegios en las últimas 24 horas + privilege_count = (await db.execute( + select(func.count()).select_from(AuditLog).where( + AuditLog.action == 'user.update', + AuditLog.created_at >= today_start, + *([AuditLog.tenant_id == tenant_filter] if apply_tenant else []) + ) + )).scalar() or 0 + + # Calcular número real de incidentes usando los mismos umbrales + # que los detectores en /security/incidents: + # - Fuerza bruta: incidente si hay >= 20 intentos fallidos + # - Eliminación masiva: incidente si hay >= 50 eliminaciones + # - Escalada privilegios: incidente si hay >= 3 cambios de rol + critical_actions_today = sum([ + 1 if failed_login_count >= 20 else 0, + 1 if deletion_count >= 50 else 0, + 1 if privilege_count >= 3 else 0, + ]) + + # ------------------------------------------------------------------ + # DATOS PARA GRÁFICAS Y TABLAS DEL DASHBOARD + # ------------------------------------------------------------------ + + # Top acciones más frecuentes (ej: "ticket.create", "user.login") top_actions = await get_top_items(db, AuditLog.action, tenant_filter) + + # Distribución por tipo de recurso (ej: "ticket", "user", "tenant") by_resource_type = await get_top_items(db, AuditLog.resource_type, tenant_filter, limit=10) + + # Usuarios más activos (hace join con tabla de usuarios) top_users = await get_top_items(db, None, tenant_filter, join_user=True) - - return AuditLogStats(total_actions=total_actions, actions_today=actions_today, - actions_this_week=actions_this_week, critical_actions_today=critical_actions_today, - top_actions=top_actions, top_users=top_users, by_resource_type=by_resource_type) + + return AuditLogStats( + total_actions=total_actions, + actions_today=actions_today, + actions_this_week=actions_this_week, + critical_actions_today=critical_actions_today, + top_actions=top_actions, + top_users=top_users, + by_resource_type=by_resource_type + ) + + +# ============================================================================= +# ENDPOINT: DETALLE DE UN LOG ESPECÍFICO +# ============================================================================= @router.get("/{log_id}", response_model=AuditLogResponse) -async def get_audit_log_detail(log_id: uuid.UUID, current_user: User = Depends(require_auditor_role), - current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)): - """Obtener detalle de un log de auditoría""" +async def get_audit_log_detail( + log_id: uuid.UUID, + current_user: User = Depends(require_auditor_role), + current_tenant: Tenant = Depends(get_current_tenant), + db: AsyncSession = Depends(get_db) +): + """ + Obtener el detalle completo de un log de auditoría por su ID. + + Incluye información del usuario que realizó la acción, valores + anteriores y nuevos (para cambios), IP de origen, user agent, etc. + + Retorna 404 si el log no existe o no pertenece al tenant del usuario. + """ + # Buscar el log por ID incluyendo los datos del usuario relacionado query = select(AuditLog).where(AuditLog.id == log_id).options(selectinload(AuditLog.user)) + + # Aplicar filtro de tenant para garantizar aislamiento multi-tenant query = apply_tenant_filter(query, current_user, current_tenant) - + result = await db.execute(query) log = result.scalar_one_or_none() - + if not log: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Audit log {log_id} not found") - + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Registro de auditoria {log_id} no encontrado" + ) + return AuditLogResponse(**audit_log_to_dict(log)) + +# ============================================================================= +# ENDPOINT: ANÁLISIS DE SEGURIDAD EN TIEMPO REAL +# ============================================================================= + @router.get("/security/analysis", response_model=SecurityAnalysisResponse) -async def get_security_analysis(all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role), - current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)): - """Análisis de seguridad basado en logs de auditoría""" - logger.info("Security analysis requested", user_id=str(current_user.id), tenant_id=str(current_tenant.id)) - +async def get_security_analysis( + hours: int = Query(default=24, ge=1, le=720), + all_tenants: bool = Query(False), + current_user: User = Depends(require_auditor_role), + current_tenant: Tenant = Depends(get_current_tenant), + db: AsyncSession = Depends(get_db) +): + """ + Analizar los logs de auditoría para detectar patrones sospechosos. + + Detecta tres tipos de amenazas: + 1. Fuerza bruta: Muchos intentos fallidos de login desde las mismas IPs + 2. Eliminación masiva: Gran cantidad de registros eliminados en poco tiempo + 3. Escalada privilegios: Cambios de roles sospechosos en usuarios + + Calcula un nivel de riesgo general (low/medium/high/critical) y + devuelve recomendaciones de acción. + """ + logger.info( + "Analisis de seguridad solicitado", + user_id=str(current_user.id), + tenant_id=str(current_tenant.id), + hours=hours + ) + + # aware UTC para compatibilidad con TIMESTAMPTZ de PostgreSQL now = datetime.now(timezone.utc) - analysis_start = now - timedelta(hours=24) - - query = select(AuditLog).where(AuditLog.created_at >= analysis_start).options(selectinload(AuditLog.user)) + analysis_start = now - timedelta(hours=hours) + + query = ( + select(AuditLog) + .where(AuditLog.created_at >= analysis_start) + .options(selectinload(AuditLog.user)) + ) query = apply_tenant_filter(query, current_user, current_tenant, all_tenants) - + result = await db.execute(query) logs = result.scalars().all() - + + # ------------------------------------------------------------------ + # CONTADORES DE EVENTOS SOSPECHOSOS + # ------------------------------------------------------------------ + failed_logins = sum(1 for log in logs if log.action == 'user.login_failed') mass_deletions = sum(1 for log in logs if '.delete' in log.action) - privilege_changes = sum(1 for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values) - + privilege_changes = sum( + 1 for log in logs + if log.action == 'user.update' + and log.new_values + and 'role' in log.new_values + ) + + # ------------------------------------------------------------------ + # GENERACIÓN DE PATRONES DE AMENAZA + # ------------------------------------------------------------------ + threat_patterns = [] - + + # Amenaza 1: Fuerza bruta (umbral mínimo: 5 intentos fallidos) if failed_logins >= 5: - affected_ips_list = [str(log.ip_address) for log in logs if log.action == 'user.login_failed' and log.ip_address] + affected_ips_list = [ + str(log.ip_address) + for log in logs + if log.action == 'user.login_failed' and log.ip_address + ] threat_patterns.append(SecurityThreatPattern( id="brute_force_attempt", type="brute_force", - description=f"Se detectaron {failed_logins} intentos fallidos de login en las últimas 24h", + description=( + f"Se detectaron {failed_logins} intentos fallidos de " + f"login en las ultimas {hours}h" + ), severity="high" if failed_logins >= 20 else "medium", occurrences=failed_logins, - first_seen=min((log.created_at for log in logs if log.action == 'user.login_failed'), default=now), - last_seen=max((log.created_at for log in logs if log.action == 'user.login_failed'), default=now), + first_seen=min( + (log.created_at for log in logs if log.action == 'user.login_failed'), + default=now + ), + last_seen=max( + (log.created_at for log in logs if log.action == 'user.login_failed'), + default=now + ), affected_ips=list(set(affected_ips_list))[:5], affected_users=[], - recommended_action="Considerar bloquear IPs con múltiples fallos" + recommended_action="Considerar bloquear IPs con multiples fallos" )) - + + # Amenaza 2: Eliminación masiva (umbral mínimo: 10 eliminaciones) if mass_deletions >= 10: - deleting_users = [log.user.email for log in logs if '.delete' in log.action and log.user] + deleting_users = [ + log.user.email + for log in logs + if '.delete' in log.action and log.user + ] threat_patterns.append(SecurityThreatPattern( id="mass_deletion", type="mass_deletion", - description=f"Se detectaron {mass_deletions} eliminaciones en las últimas 24h", + description=( + f"Se detectaron {mass_deletions} eliminaciones en " + f"las ultimas {hours}h" + ), severity="critical" if mass_deletions >= 50 else "high", occurrences=mass_deletions, - first_seen=min((log.created_at for log in logs if '.delete' in log.action), default=now), - last_seen=max((log.created_at for log in logs if '.delete' in log.action), default=now), + first_seen=min( + (log.created_at for log in logs if '.delete' in log.action), + default=now + ), + last_seen=max( + (log.created_at for log in logs if '.delete' in log.action), + default=now + ), affected_ips=[], affected_users=list(set(deleting_users))[:5], - recommended_action="Revisar qué usuarios están eliminando recursos" + recommended_action="Revisar que usuarios estan eliminando recursos masivamente" )) - + + # Amenaza 3: Escalada de privilegios (umbral mínimo: 3 cambios de rol) if privilege_changes >= 3: - affected_users_list = [log.user.email for log in logs if log.action == 'user.update' and log.user and log.new_values and 'role' in log.new_values] + affected_users_list = [ + log.user.email + for log in logs + if log.action == 'user.update' + and log.user + and log.new_values + and 'role' in log.new_values + ] threat_patterns.append(SecurityThreatPattern( id="suspicious_privilege_changes", type="privilege_escalation", - description=f"Se detectaron {privilege_changes} cambios de privilegios en las últimas 24h", + description=( + f"Se detectaron {privilege_changes} cambios de " + f"privilegios en las ultimas {hours}h" + ), severity="high", occurrences=privilege_changes, - first_seen=min((log.created_at for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values), default=now), - last_seen=max((log.created_at for log in logs if log.action == 'user.update' and log.new_values and 'role' in log.new_values), default=now), + first_seen=min( + ( + log.created_at for log in logs + if log.action == 'user.update' + and log.new_values + and 'role' in log.new_values + ), + default=now + ), + last_seen=max( + ( + log.created_at for log in logs + if log.action == 'user.update' + and log.new_values + and 'role' in log.new_values + ), + default=now + ), affected_ips=[], affected_users=list(set(affected_users_list))[:5], recommended_action="Auditar cambios de roles recientes" )) - - risk_score = min(100, (failed_logins * 2) + (mass_deletions * 5) + (privilege_changes * 10)) - risk_level = "critical" if risk_score >= 80 else "high" if risk_score >= 50 else "medium" if risk_score >= 20 else "low" - + + # ------------------------------------------------------------------ + # CÁLCULO DE NIVEL DE RIESGO GENERAL + # ------------------------------------------------------------------ + + risk_score = min( + 100, + (failed_logins * 2) + (mass_deletions * 5) + (privilege_changes * 10) + ) + + if risk_score >= 80: + risk_level = "critical" + elif risk_score >= 50: + risk_level = "high" + elif risk_score >= 20: + risk_level = "medium" + else: + risk_level = "low" + + # ------------------------------------------------------------------ + # RECOMENDACIONES AUTOMÁTICAS + # ------------------------------------------------------------------ + recommended_actions = [] + if failed_logins >= 20: - recommended_actions.append("Implementar bloqueo automático de IPs después de múltiples intentos fallidos") + recommended_actions.append( + "Implementar bloqueo automatico de IPs despues de multiples intentos fallidos" + ) if mass_deletions >= 50: - recommended_actions.append("Activar confirmación adicional para eliminaciones masivas") + recommended_actions.append( + "Activar confirmacion adicional para eliminaciones masivas" + ) + if privilege_changes >= 3: + recommended_actions.append( + "Revisar y aprobar manualmente los cambios de roles recientes" + ) if not recommended_actions: recommended_actions.append("Continuar monitoreando actividad del sistema") - - # Calcular IPs sospechosas (más de 5 intentos fallidos) - suspicious_ips = len(set([log.ip_address for log in logs if log.ip_address and log.action == 'user.login_failed'])) - - # Contar acciones críticas (delete, privilege changes, etc) + + suspicious_ips = len(set( + log.ip_address + for log in logs + if log.ip_address and log.action == 'user.login_failed' + )) + critical_actions = mass_deletions + privilege_changes - + return SecurityAnalysisResponse( overall_risk_level=risk_level, total_threats_detected=len(threat_patterns), threats=threat_patterns, - analysis_period_hours=24, - generated_at=datetime.utcnow(), + analysis_period_hours=hours, + generated_at=datetime.now(timezone.utc), failed_login_attempts=failed_logins, suspicious_ips_count=suspicious_ips, critical_actions_count=critical_actions, recommended_actions=recommended_actions ) + +# ============================================================================= +# ENDPOINT: EJECUTAR ACCIÓN DE SEGURIDAD +# ============================================================================= + @router.post("/security/action", response_model=SecurityActionResponse) -async def execute_security_action(action: SecurityActionRequest, current_user: User = Depends(require_auditor_role), - current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)): - """Ejecutar acción de seguridad""" +async def execute_security_action( + action: SecurityActionRequest, + current_user: User = Depends(require_auditor_role), + current_tenant: Tenant = Depends(get_current_tenant), + db: AsyncSession = Depends(get_db) +): + """ + Ejecutar una acción de seguridad manual sobre una amenaza detectada. + + Acciones disponibles: + - block_ip: Bloquear una dirección IP por X minutos + - notify_admin: Enviar notificación a los administradores + - force_password_reset: Forzar cambio de contraseña a un usuario + - disable_user: Desactivar temporalmente una cuenta de usuario + + Solo ADMIN y SUPPORT_MANAGER pueden ejecutar estas acciones. + Todas las acciones quedan registradas en el log de auditoría. + """ if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, - detail="Solo administradores pueden ejecutar acciones de seguridad") - - logger.info("Security action requested", user_id=str(current_user.id), - action_type=action.action_type, target=action.target) - + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Solo administradores pueden ejecutar acciones de seguridad" + ) + + logger.info( + "Accion de seguridad solicitada", + user_id=str(current_user.id), + action_type=action.action_type, + target=action.target + ) + + # Registrar en auditoría para trazabilidad completa try: - await AuditService.log(db=db, tenant_id=current_tenant.id, user_id=current_user.id, - action=f"security.{action.action_type}", resource_type="security", resource_id=None, - metadata={"target": action.target, "reason": action.reason, "duration_minutes": action.duration_minutes}) + await AuditService.log( + db=db, + tenant_id=current_tenant.id, + user_id=current_user.id, + action=f"security.{action.action_type}", + resource_type="security", + resource_id=None, + metadata={ + "target": action.target, + "reason": action.reason, + "duration_minutes": action.duration_minutes + } + ) await db.commit() except Exception as e: - logger.error("Failed to log security action", error=str(e)) - + logger.error("Fallo al registrar accion de seguridad en auditoria", error=str(e)) + action_messages = { - "block_ip": f"IP {action.target} bloqueada por {action.duration_minutes or 60} minutos. Razón: {action.reason}", - "notify_admin": f"Notificación enviada a administradores sobre: {action.reason}", - "force_password_reset": f"Se forzará cambio de contraseña para {action.target}. Razón: {action.reason}", - "disable_user": f"Usuario {action.target} desactivado temporalmente. Razón: {action.reason}" + "block_ip": ( + f"IP {action.target} bloqueada por " + f"{action.duration_minutes or 60} minutos. Razon: {action.reason}" + ), + "notify_admin": ( + f"Notificacion enviada a administradores sobre: {action.reason}" + ), + "force_password_reset": ( + f"Se forzara cambio de contrasena para {action.target}. " + f"Razon: {action.reason}" + ), + "disable_user": ( + f"Usuario {action.target} desactivado temporalmente. " + f"Razon: {action.reason}" + ) } - + success = action.action_type in action_messages - message = action_messages.get(action.action_type, f"Tipo de acción no reconocida: {action.action_type}") - + message = action_messages.get( + action.action_type, + f"Tipo de accion no reconocida: {action.action_type}" + ) + return SecurityActionResponse(success=success, message=message, action_id=None) + +# ============================================================================= +# ENDPOINT: LISTA DE INCIDENTES DE SEGURIDAD +# ============================================================================= + @router.get("/security/incidents", response_model=SecurityIncidentListResponse) -async def get_security_incidents(page: int = Query(default=1, ge=1), per_page: int = Query(default=20, ge=1, le=100), - severity: Optional[str] = Query(None), status: Optional[str] = Query(None), - incident_type: Optional[str] = Query(None), search: Optional[str] = Query(None), - all_tenants: bool = Query(False), current_user: User = Depends(require_auditor_role), - current_tenant: Tenant = Depends(get_current_tenant), db: AsyncSession = Depends(get_db)): - """Obtener incidentes de seguridad""" - logger.info("Fetching security incidents", user_id=str(current_user.id), tenant_id=str(current_tenant.id), - filters={"severity": severity, "status": status, "type": incident_type, "page": page}) - +async def get_security_incidents( + # Paginación + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + # Filtros opcionales + severity: Optional[str] = Query(None), + status: Optional[str] = Query(None), + incident_type: Optional[str] = Query(None), + search: Optional[str] = Query(None), + all_tenants: bool = Query(False), + # Dependencias + current_user: User = Depends(require_auditor_role), + current_tenant: Tenant = Depends(get_current_tenant), + db: AsyncSession = Depends(get_db) +): + """ + Obtener la lista de incidentes de seguridad detectados. + + Los incidentes se generan dinámicamente analizando los logs de + auditoría de los últimos 7 días usando tres detectores: + + 1. detect_brute_force: Analiza intentos fallidos de login + 2. detect_mass_deletions: Analiza eliminaciones masivas + 3. detect_privilege_escalation: Analiza cambios de rol sospechosos + + Los umbrales son los mismos que usa /stats para critical_actions_today, + garantizando consistencia entre el contador y la lista. + """ + logger.info( + "Obteniendo incidentes de seguridad", + user_id=str(current_user.id), + tenant_id=str(current_tenant.id), + filters={ + "severity": severity, + "status": status, + "type": incident_type, + "page": page + } + ) + + # aware UTC para compatibilidad con TIMESTAMPTZ de PostgreSQL now = datetime.now(timezone.utc) analysis_start = now - timedelta(days=7) - - base_query = select(AuditLog).options(selectinload(AuditLog.user)).where(AuditLog.created_at >= analysis_start) + + base_query = ( + select(AuditLog) + .options(selectinload(AuditLog.user)) + .where(AuditLog.created_at >= analysis_start) + ) base_query = apply_tenant_filter(base_query, current_user, current_tenant, all_tenants) - - deletion_result = await db.execute(base_query.where(AuditLog.action.like('%.delete')).order_by(desc(AuditLog.created_at))) + + # ------------------------------------------------------------------ + # DETECTOR 1: ELIMINACIONES MASIVAS + # ------------------------------------------------------------------ + deletion_result = await db.execute( + base_query + .where(AuditLog.action.like('%.delete')) + .order_by(desc(AuditLog.created_at)) + ) deletion_logs = deletion_result.scalars().all() deletion_incidents = detect_mass_deletions(deletion_logs, now) - - failed_login_result = await db.execute(base_query.where(AuditLog.action == 'user.login_failed').order_by(desc(AuditLog.created_at))) + + # ------------------------------------------------------------------ + # DETECTOR 2: FUERZA BRUTA + # ------------------------------------------------------------------ + failed_login_result = await db.execute( + base_query + .where(AuditLog.action == 'user.login_failed') + .order_by(desc(AuditLog.created_at)) + ) failed_login_logs = failed_login_result.scalars().all() brute_force_incidents = detect_brute_force(failed_login_logs, now) - - privilege_result = await db.execute(base_query.where(and_(AuditLog.action == 'user.update', AuditLog.new_values.op('?')('role'))).order_by(desc(AuditLog.created_at))) + + # ------------------------------------------------------------------ + # DETECTOR 3: ESCALADA DE PRIVILEGIOS + # El operador '?' verifica si el campo JSON contiene la clave 'role' + # ------------------------------------------------------------------ + privilege_result = await db.execute( + base_query + .where(and_( + AuditLog.action == 'user.update', + AuditLog.new_values.op('?')('role') + )) + .order_by(desc(AuditLog.created_at)) + ) privilege_logs = privilege_result.scalars().all() privilege_incidents = detect_privilege_escalation(privilege_logs) - - incidents = [SecurityIncidentResponse(**inc) for inc in (deletion_incidents + brute_force_incidents + privilege_incidents)] - + + # Combinar todos los incidentes + incidents = [ + SecurityIncidentResponse(**inc) + for inc in (deletion_incidents + brute_force_incidents + privilege_incidents) + ] + + # ------------------------------------------------------------------ + # FILTROS EN MEMORIA (los incidentes son generados dinámicamente) + # ------------------------------------------------------------------ + if severity: incidents = [i for i in incidents if i.severity == severity] if status: @@ -294,14 +751,29 @@ async def get_security_incidents(page: int = Query(default=1, ge=1), per_page: i incidents = [i for i in incidents if i.incident_type == incident_type] if search: search_lower = search.lower() - incidents = [i for i in incidents if search_lower in i.title.lower() or (i.description and search_lower in i.description.lower())] - + incidents = [ + i for i in incidents + if search_lower in i.title.lower() + or (i.description and search_lower in i.description.lower()) + ] + + # Ordenar por fecha descendente incidents.sort(key=lambda x: x.created_at, reverse=True) - + + # ------------------------------------------------------------------ + # PAGINACIÓN MANUAL + # ------------------------------------------------------------------ + total = len(incidents) total_pages = (total + per_page - 1) // per_page start_idx = (page - 1) * per_page end_idx = start_idx + per_page paginated_incidents = incidents[start_idx:end_idx] - - return SecurityIncidentListResponse(incidents=paginated_incidents, total=total, page=page, per_page=per_page, total_pages=total_pages) + + return SecurityIncidentListResponse( + incidents=paginated_incidents, + total=total, + page=page, + per_page=per_page, + total_pages=total_pages + ) \ No newline at end of file diff --git a/backend/app/api/v1/endpoints/reports.py b/backend/app/api/v1/endpoints/reports.py new file mode 100644 index 0000000..aa1b704 --- /dev/null +++ b/backend/app/api/v1/endpoints/reports.py @@ -0,0 +1,761 @@ +""" +Reports Endpoints - ServiceManagerWeb + +Módulo de reportes y estadísticas del sistema. +Accesible por ADMIN y SUPPORT_MANAGER. +""" + +from fastapi import APIRouter, Depends, Query, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_, case, text +from typing import Optional, List +from datetime import datetime, timedelta, timezone +import uuid + +from app.core.database import get_db +from app.api.deps import get_current_user +from app.models.user import User, UserRole +from app.models.ticket import Ticket, TicketStatus, TicketPriority +from app.models.category import Category +from app.models.system import System +from app.models.tenant import Tenant, TenantStatus +from app.api.schemas.reports import ( + ReportSummaryResponse, + TicketsByStatus, + TicketsByPriority, + AgentReportResponse, + AgentReportRow, + CategoryReportResponse, + CategoryReportRow, + ClientReportResponse, + ClientReportRow, + TrendsReportResponse, + TrendDataPoint, + CSATReportResponse, + CSATDistribution, + SystemReportResponse, + SystemReportRow, +) + +router = APIRouter() + +CLOSED_STATUSES = {TicketStatus.RESOLVED, TicketStatus.CLOSED} + +# =================================== +# HELPERS +# =================================== + +def require_reports_access(current_user: User = Depends(get_current_user)) -> User: + """ADMIN, SUPPORT_MANAGER y AUDITOR pueden leer reportes.""" + allowed = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR] + if current_user.role not in allowed: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Solo ADMIN, SUPPORT_MANAGER y AUDITOR pueden acceder a los reportes.", + ) + return current_user + + +def require_admin(current_user: User = Depends(get_current_user)) -> User: + """Solo ADMIN puede ver reportes entre tenants.""" + if current_user.role != UserRole.ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Solo ADMIN puede ver reportes de todos los clientes.", + ) + return current_user + + +def _period_dates(days: int) -> tuple[datetime, datetime]: + """Devuelve (inicio, fin) del período solicitado en UTC.""" + end = datetime.now(timezone.utc) + start = end - timedelta(days=days) + return start, end + + +# =================================== +# 1. RESUMEN GENERAL +# =================================== + +@router.get("/summary", response_model=ReportSummaryResponse) +async def get_report_summary( + days: int = Query(default=30, ge=1, le=365, description="Días hacia atrás del período"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_reports_access), +): + """ + Resumen ejecutivo del período seleccionado. + + Incluye: + - Total de tickets creados + - Tickets abiertos vs resueltos + - Tiempo promedio de resolución + - Calificación promedio (CSAT) + - Desglose por estado y prioridad + - Comparación con el período anterior + """ + period_start, period_end = _period_dates(days) + prev_start = period_start - timedelta(days=days) + + tenant_filter = Ticket.tenant_id == current_user.tenant_id + + # ── Conteos por estado ── + status_rows = (await db.execute( + select(Ticket.status, func.count(Ticket.id).label("cnt")) + .where(and_(tenant_filter, Ticket.created_at >= period_start)) + .group_by(Ticket.status) + )).all() + + by_status = TicketsByStatus() + for row in status_rows: + s = row.status.value if hasattr(row.status, "value") else str(row.status) + setattr(by_status, s.lower(), row.cnt) + by_status.total = sum( + [by_status.new, by_status.triage, by_status.in_progress, + by_status.waiting_customer, by_status.resolved, by_status.closed, by_status.reopened] + ) + + # ── Conteos por prioridad ── + priority_rows = (await db.execute( + select(Ticket.priority, func.count(Ticket.id).label("cnt")) + .where(and_(tenant_filter, Ticket.created_at >= period_start)) + .group_by(Ticket.priority) + )).all() + + by_priority = TicketsByPriority() + for row in priority_rows: + p = row.priority.value if hasattr(row.priority, "value") else str(row.priority) + setattr(by_priority, p.lower(), row.cnt) + by_priority.total = sum([by_priority.low, by_priority.medium, by_priority.high, by_priority.urgent]) + + total_tickets = by_status.total + resolved_tickets = by_status.resolved + by_status.closed + open_tickets = total_tickets - resolved_tickets + + # ── Promedio de tiempo de resolución (segundos → horas) ── + res_time_row = (await db.execute( + select(func.avg( + func.extract("epoch", Ticket.resolved_at - Ticket.created_at) + ).label("avg_seconds")) + .where(and_( + tenant_filter, + Ticket.created_at >= period_start, + Ticket.resolved_at.isnot(None), + )) + )).scalar_one_or_none() + avg_resolution_hours = round(res_time_row / 3600, 2) if res_time_row else None + + # ── Promedio de primera respuesta ── + resp_time_row = (await db.execute( + select(func.avg( + func.extract("epoch", Ticket.first_response_at - Ticket.created_at) + ).label("avg_seconds")) + .where(and_( + tenant_filter, + Ticket.created_at >= period_start, + Ticket.first_response_at.isnot(None), + )) + )).scalar_one_or_none() + avg_first_response_hours = round(resp_time_row / 3600, 2) if resp_time_row else None + + # ── CSAT ── + csat_row = (await db.execute( + select(func.avg(Ticket.rating).label("avg"), func.count(Ticket.rating).label("cnt")) + .where(and_(tenant_filter, Ticket.created_at >= period_start, Ticket.rating.isnot(None))) + )).one() + avg_rating = round(float(csat_row.avg), 2) if csat_row.avg else None + total_rated = csat_row.cnt or 0 + + # ── Comparación con período anterior ── + prev_total = (await db.execute( + select(func.count(Ticket.id)) + .where(and_(tenant_filter, Ticket.created_at >= prev_start, Ticket.created_at < period_start)) + )).scalar_one_or_none() or 0 + + prev_resolved = (await db.execute( + select(func.count(Ticket.id)) + .where(and_( + tenant_filter, + Ticket.created_at >= prev_start, + Ticket.created_at < period_start, + Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]), + )) + )).scalar_one_or_none() or 0 + + tickets_change_pct = None + if prev_total > 0: + tickets_change_pct = round(((total_tickets - prev_total) / prev_total) * 100, 1) + + resolution_change_pct = None + if prev_total > 0 and total_tickets > 0: + cur_rate = resolved_tickets / total_tickets * 100 + prev_rate = prev_resolved / prev_total * 100 if prev_total > 0 else 0 + resolution_change_pct = round(cur_rate - prev_rate, 1) + + return ReportSummaryResponse( + period_start=period_start, + period_end=period_end, + generated_at=datetime.now(timezone.utc), + total_tickets=total_tickets, + open_tickets=open_tickets, + resolved_tickets=resolved_tickets, + avg_resolution_hours=avg_resolution_hours, + avg_first_response_hours=avg_first_response_hours, + avg_rating=avg_rating, + total_rated=total_rated, + by_status=by_status, + by_priority=by_priority, + tickets_change_pct=tickets_change_pct, + resolution_change_pct=resolution_change_pct, + ) + + +# =================================== +# 2. RENDIMIENTO POR AGENTE +# =================================== + +@router.get("/by-agent", response_model=AgentReportResponse) +async def get_report_by_agent( + days: int = Query(default=30, ge=1, le=365), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_reports_access), +): + """ + Rendimiento de cada agente en el período: + - Tickets asignados y resueltos + - Tasa de resolución + - Tiempo promedio de resolución + - Calificación promedio (CSAT) + """ + period_start, period_end = _period_dates(days) + tenant_filter = and_( + Ticket.tenant_id == current_user.tenant_id, + Ticket.created_at >= period_start, + Ticket.assigned_to.isnot(None), + ) + + # Obtener todos los agentes del tenant + agents_result = await db.execute( + select(User).where( + and_( + User.tenant_id == current_user.tenant_id, + User.role.in_([UserRole.AGENT, UserRole.SUPPORT_MANAGER, UserRole.ADMIN]), + User.is_active == True, + ) + ) + ) + agents = agents_result.scalars().all() + + rows: List[AgentReportRow] = [] + for agent in agents: + agent_filter = and_(tenant_filter, Ticket.assigned_to == agent.id) + + total_assigned = (await db.execute( + select(func.count(Ticket.id)).where(agent_filter) + )).scalar_one_or_none() or 0 + + if total_assigned == 0: + continue # omitir agentes sin tickets en el período + + resolved = (await db.execute( + select(func.count(Ticket.id)).where( + and_(agent_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED])) + ) + )).scalar_one_or_none() or 0 + + avg_res_seconds = (await db.execute( + select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at))) + .where(and_(agent_filter, Ticket.resolved_at.isnot(None))) + )).scalar_one_or_none() + + csat = (await db.execute( + select(func.avg(Ticket.rating), func.count(Ticket.rating)) + .where(and_(agent_filter, Ticket.rating.isnot(None))) + )).one() + + urgent_handled = (await db.execute( + select(func.count(Ticket.id)).where( + and_(agent_filter, Ticket.priority == TicketPriority.URGENT) + ) + )).scalar_one_or_none() or 0 + + rows.append(AgentReportRow( + agent_id=str(agent.id), + agent_name=f"{agent.first_name} {agent.last_name}", + agent_email=agent.email, + total_assigned=total_assigned, + resolved=resolved, + open=total_assigned - resolved, + resolution_rate=round((resolved / total_assigned * 100), 1) if total_assigned else 0, + avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None, + avg_rating=round(float(csat[0]), 2) if csat[0] else None, + total_rated=csat[1] or 0, + urgent_handled=urgent_handled, + )) + + rows.sort(key=lambda r: r.resolved, reverse=True) + + return AgentReportResponse( + period_start=period_start, + period_end=period_end, + generated_at=datetime.now(timezone.utc), + agents=rows, + total_agents=len(rows), + ) + + +# =================================== +# 3. TICKETS POR CATEGORÍA +# =================================== + +@router.get("/by-category", response_model=CategoryReportResponse) +async def get_report_by_category( + days: int = Query(default=30, ge=1, le=365), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_reports_access), +): + """ + Tickets agrupados por categoría con tasa de cumplimiento SLA. + """ + period_start, _ = _period_dates(days) + period_end = datetime.now(timezone.utc) + tenant_filter = and_( + Ticket.tenant_id == current_user.tenant_id, + Ticket.created_at >= period_start, + ) + + categories_result = await db.execute( + select(Category).where( + and_(Category.tenant_id == current_user.tenant_id, Category.is_active == True) + ) + ) + categories = categories_result.scalars().all() + + rows: List[CategoryReportRow] = [] + + for cat in categories: + cat_filter = and_(tenant_filter, Ticket.category_id == cat.id) + + total = (await db.execute( + select(func.count(Ticket.id)).where(cat_filter) + )).scalar_one_or_none() or 0 + + if total == 0: + continue + + resolved = (await db.execute( + select(func.count(Ticket.id)).where( + and_(cat_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED])) + ) + )).scalar_one_or_none() or 0 + + avg_res_seconds = (await db.execute( + select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at))) + .where(and_(cat_filter, Ticket.resolved_at.isnot(None))) + )).scalar_one_or_none() + + # SLA compliance: tickets resueltos ANTES del deadline + sla_met = (await db.execute( + select(func.count(Ticket.id)).where( + and_( + cat_filter, + Ticket.resolved_at.isnot(None), + Ticket.sla_resolution_due.isnot(None), + Ticket.resolved_at <= Ticket.sla_resolution_due, + ) + ) + )).scalar_one_or_none() or 0 + + tickets_with_sla = (await db.execute( + select(func.count(Ticket.id)).where( + and_(cat_filter, Ticket.sla_resolution_due.isnot(None), Ticket.resolved_at.isnot(None)) + ) + )).scalar_one_or_none() or 0 + + sla_compliance_pct = round((sla_met / tickets_with_sla * 100), 1) if tickets_with_sla else 0.0 + + rows.append(CategoryReportRow( + category_id=str(cat.id), + category_name=cat.name, + total_tickets=total, + open_tickets=total - resolved, + resolved_tickets=resolved, + avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None, + sla_response_hours=cat.sla_response_hours, + sla_resolution_hours=cat.sla_resolution_hours, + sla_compliance_pct=sla_compliance_pct, + )) + + # Sin categoría + uncategorized = (await db.execute( + select(func.count(Ticket.id)).where( + and_(tenant_filter, Ticket.category_id.is_(None)) + ) + )).scalar_one_or_none() or 0 + + rows.sort(key=lambda r: r.total_tickets, reverse=True) + + return CategoryReportResponse( + period_start=period_start, + period_end=period_end, + generated_at=datetime.now(timezone.utc), + categories=rows, + uncategorized_count=uncategorized, + ) + + +# =================================== +# 4. TICKETS POR CLIENTE (solo ADMIN) +# =================================== + +@router.get("/by-client", response_model=ClientReportResponse) +async def get_report_by_client( + days: int = Query(default=30, ge=1, le=365), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_admin), +): + """ + Tickets agrupados por cliente (tenant). Solo accesible por ADMIN. + Útil para ver qué clientes generan más trabajo. + """ + period_start, period_end = _period_dates(days) + + tenants_result = await db.execute(select(Tenant).where(Tenant.status == TenantStatus.ACTIVE)) + tenants = tenants_result.scalars().all() + + rows: List[ClientReportRow] = [] + + for tenant in tenants: + t_filter = and_( + Ticket.tenant_id == tenant.id, + Ticket.created_at >= period_start, + ) + + total = (await db.execute( + select(func.count(Ticket.id)).where(t_filter) + )).scalar_one_or_none() or 0 + + if total == 0: + continue + + resolved = (await db.execute( + select(func.count(Ticket.id)).where( + and_(t_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED])) + ) + )).scalar_one_or_none() or 0 + + urgent = (await db.execute( + select(func.count(Ticket.id)).where( + and_(t_filter, Ticket.priority == TicketPriority.URGENT) + ) + )).scalar_one_or_none() or 0 + + csat_row = (await db.execute( + select(func.avg(Ticket.rating)) + .where(and_(t_filter, Ticket.rating.isnot(None))) + )).scalar_one_or_none() + + avg_res_seconds = (await db.execute( + select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at))) + .where(and_(t_filter, Ticket.resolved_at.isnot(None))) + )).scalar_one_or_none() + + last_ticket = (await db.execute( + select(func.max(Ticket.created_at)).where(t_filter) + )).scalar_one_or_none() + + rows.append(ClientReportRow( + tenant_id=str(tenant.id), + tenant_name=tenant.name, + total_tickets=total, + open_tickets=total - resolved, + resolved_tickets=resolved, + urgent_tickets=urgent, + avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None, + avg_rating=round(float(csat_row), 2) if csat_row else None, + last_ticket_at=last_ticket, + )) + + rows.sort(key=lambda r: r.total_tickets, reverse=True) + + return ClientReportResponse( + period_start=period_start, + period_end=period_end, + generated_at=datetime.now(timezone.utc), + clients=rows, + total_clients=len(rows), + ) + + +# =================================== +# 5. TENDENCIAS (TICKETS EN EL TIEMPO) +# =================================== + +@router.get("/trends", response_model=TrendsReportResponse) +async def get_report_trends( + days: int = Query(default=30, ge=7, le=90, description="Número de días (7-90)"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_reports_access), +): + """ + Evolución diaria de tickets creados y resueltos. + Útil para detectar picos de trabajo. + """ + period_start, period_end = _period_dates(days) + tenant_filter = Ticket.tenant_id == current_user.tenant_id + + # Tickets creados por día + created_rows = (await db.execute( + select( + func.date_trunc("day", Ticket.created_at).label("day"), + func.count(Ticket.id).label("cnt"), + ) + .where(and_(tenant_filter, Ticket.created_at >= period_start)) + .group_by(func.date_trunc("day", Ticket.created_at)) + .order_by(func.date_trunc("day", Ticket.created_at)) + )).all() + + # Tickets resueltos por día (según resolved_at) + resolved_rows = (await db.execute( + select( + func.date_trunc("day", Ticket.resolved_at).label("day"), + func.count(Ticket.id).label("cnt"), + ) + .where(and_( + tenant_filter, + Ticket.resolved_at >= period_start, + Ticket.resolved_at.isnot(None), + )) + .group_by(func.date_trunc("day", Ticket.resolved_at)) + .order_by(func.date_trunc("day", Ticket.resolved_at)) + )).all() + + created_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in created_rows} + resolved_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in resolved_rows} + + # Un punto por cada día del período + data_points: List[TrendDataPoint] = [] + current = period_start + while current <= period_end: + date_str = current.strftime("%Y-%m-%d") + c = created_map.get(date_str, 0) + r = resolved_map.get(date_str, 0) + data_points.append(TrendDataPoint(date=date_str, created=c, resolved=r, net_open=c - r)) + current += timedelta(days=1) + + return TrendsReportResponse( + period_start=period_start, + period_end=period_end, + generated_at=datetime.now(timezone.utc), + data_points=data_points, + total_days=len(data_points), + ) + + +# =================================== +# 6. SATISFACCIÓN DEL CLIENTE (CSAT) +# =================================== + +@router.get("/csat", response_model=CSATReportResponse) +async def get_report_csat( + days: int = Query(default=30, ge=1, le=365), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_reports_access), +): + """ + Reporte de satisfacción del cliente (calificaciones 1-5). + Incluye distribución, promedio por categoría y por agente. + """ + period_start, period_end = _period_dates(days) + tenant_filter = and_( + Ticket.tenant_id == current_user.tenant_id, + Ticket.created_at >= period_start, + ) + + # Total y promedio general + general = (await db.execute( + select(func.avg(Ticket.rating).label("avg"), func.count(Ticket.rating).label("rated")) + .where(and_(tenant_filter, Ticket.rating.isnot(None))) + )).one() + total_tickets = (await db.execute( + select(func.count(Ticket.id)).where(tenant_filter) + )).scalar_one_or_none() or 0 + + # Distribución por estrellas + dist_rows = (await db.execute( + select(Ticket.rating, func.count(Ticket.id).label("cnt")) + .where(and_(tenant_filter, Ticket.rating.isnot(None))) + .group_by(Ticket.rating) + )).all() + + dist = CSATDistribution() + for row in dist_rows: + setattr(dist, f"rating_{row.rating}", row.cnt) + + # Promedio por categoría + cat_rows = (await db.execute( + select( + Category.name.label("cat_name"), + func.avg(Ticket.rating).label("avg"), + func.count(Ticket.rating).label("cnt"), + ) + .join(Category, Ticket.category_id == Category.id, isouter=True) + .where(and_(tenant_filter, Ticket.rating.isnot(None))) + .group_by(Category.name) + .order_by(func.avg(Ticket.rating).desc()) + )).all() + + by_category = [ + { + "category": row.cat_name or "Sin categoría", + "avg_rating": round(float(row.avg), 2) if row.avg else None, + "total_rated": row.cnt, + } + for row in cat_rows + ] + + # Promedio por agente + agent_rows = (await db.execute( + select( + User.first_name.label("fname"), + User.last_name.label("lname"), + func.avg(Ticket.rating).label("avg"), + func.count(Ticket.rating).label("cnt"), + ) + .join(User, Ticket.assigned_to == User.id, isouter=True) + .where(and_(tenant_filter, Ticket.rating.isnot(None))) + .group_by(User.first_name, User.last_name) + .order_by(func.avg(Ticket.rating).desc()) + )).all() + + by_agent = [ + { + "agent": f"{row.fname or ''} {row.lname or ''}".strip() or "Sin asignar", + "avg_rating": round(float(row.avg), 2) if row.avg else None, + "total_rated": row.cnt, + } + for row in agent_rows + ] + + # Últimos comentarios de calificación (rating_comment) + comment_rows = (await db.execute( + select(Ticket.rating, Ticket.rating_comment, Ticket.rated_at) + .where(and_( + tenant_filter, + Ticket.rating.isnot(None), + Ticket.rating_comment.isnot(None), + Ticket.rating_comment != "", + )) + .order_by(Ticket.rated_at.desc()) + .limit(10) + )).all() + + recent_comments = [ + { + "rating": row.rating, + "comment": row.rating_comment, + "rated_at": row.rated_at.isoformat() if row.rated_at else None, + } + for row in comment_rows + ] + + total_rated = general.rated or 0 + response_rate = round((total_rated / total_tickets * 100), 1) if total_tickets else 0.0 + + return CSATReportResponse( + period_start=period_start, + period_end=period_end, + generated_at=datetime.now(timezone.utc), + avg_rating=round(float(general.avg), 2) if general.avg else None, + total_rated=total_rated, + total_tickets=total_tickets, + response_rate=response_rate, + distribution=dist, + by_category=by_category, + by_agent=by_agent, + recent_comments=recent_comments, + ) + + +# =================================== +# 7. TICKETS POR SISTEMA AFECTADO +# =================================== + +@router.get("/by-system", response_model=SystemReportResponse) +async def get_report_by_system( + days: int = Query(default=30, ge=1, le=365), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_reports_access), +): + """ + Tickets agrupados por sistema afectado. + Útil para detectar qué sistemas generan más incidentes. + """ + period_start, period_end = _period_dates(days) + tenant_filter = and_( + Ticket.tenant_id == current_user.tenant_id, + Ticket.created_at >= period_start, + ) + + systems_result = await db.execute( + select(System).where( + and_(System.tenant_id == current_user.tenant_id, System.is_active == True) + ) + ) + systems = systems_result.scalars().all() + + rows: List[SystemReportRow] = [] + + for sys in systems: + sys_filter = and_(tenant_filter, Ticket.affected_system_id == sys.id) + + total = (await db.execute( + select(func.count(Ticket.id)).where(sys_filter) + )).scalar_one_or_none() or 0 + + if total == 0: + continue + + resolved = (await db.execute( + select(func.count(Ticket.id)).where( + and_(sys_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED])) + ) + )).scalar_one_or_none() or 0 + + urgent = (await db.execute( + select(func.count(Ticket.id)).where( + and_(sys_filter, Ticket.priority == TicketPriority.URGENT) + ) + )).scalar_one_or_none() or 0 + + avg_res_seconds = (await db.execute( + select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at))) + .where(and_(sys_filter, Ticket.resolved_at.isnot(None))) + )).scalar_one_or_none() + + rows.append(SystemReportRow( + system_id=str(sys.id), + system_name=sys.name, + total_tickets=total, + open_tickets=total - resolved, + resolved_tickets=resolved, + urgent_tickets=urgent, + avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None, + )) + + # Sin sistema asignado + no_system = (await db.execute( + select(func.count(Ticket.id)).where( + and_(tenant_filter, Ticket.affected_system_id.is_(None)) + ) + )).scalar_one_or_none() or 0 + + rows.sort(key=lambda r: r.total_tickets, reverse=True) + + return SystemReportResponse( + period_start=period_start, + period_end=period_end, + generated_at=datetime.now(timezone.utc), + systems=rows, + no_system_count=no_system, + ) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index e361920..1f491bf 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -6,7 +6,7 @@ Router principal para la API v1 from fastapi import APIRouter -from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla +from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports api_router = APIRouter() @@ -73,4 +73,11 @@ api_router.include_router( sla.router, prefix="/sla", tags=["sla"] +) + +# Reports routes +api_router.include_router( + reports.router, + prefix="/reports", + tags=["reports"] ) \ No newline at end of file diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py index fbf36ff..e8520fc 100644 --- a/backend/app/models/audit.py +++ b/backend/app/models/audit.py @@ -1,7 +1,7 @@ """ Audit Log Model - ServiceManagerWeb -Modelo para bit├ícora de auditor├¡a y compliance. +Modelo para bitácora de auditoría y compliance. Registra todas las acciones importantes del sistema. """ @@ -10,7 +10,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.dialects.postgresql import INET, JSONB from typing import Optional, Dict, Any, TYPE_CHECKING import uuid -from datetime import datetime +from datetime import datetime, timezone from app.core.database import Base, GUID @@ -21,131 +21,154 @@ if TYPE_CHECKING: class AuditLog(Base): """ - Bit├ícora de auditor├¡a para tracking completo de acciones. - + Bitácora de auditoría para tracking completo de acciones. + Registra: - - Qui├®n hizo la acci├│n (user_id) - - Qu├® hizo (action) - - Sobre qu├® recurso (resource_type + resource_id) - - Cu├índo lo hizo (created_at) - - Desde d├│nde (ip_address, user_agent) - - Qu├® cambi├│ (old_values, new_values) + - Quién hizo la acción (user_id) + - Qué hizo (action) + - Sobre qué recurso (resource_type + resource_id) + - Cuándo lo hizo (created_at) + - Desde dónde (ip_address, user_agent) + - Qué cambió (old_values, new_values) """ - + __tablename__ = "audit_logs" - - # Multi-tenancy + + # Multi-tenancy: cada registro pertenece a un tenant específico tenant_id: Mapped[uuid.UUID] = mapped_column( GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True ) - - # Usuario que ejecut├│ la acci├│n (NULL = acci├│n del sistema) + + # Usuario que ejecutó la acción (NULL = acción del sistema) user_id: Mapped[Optional[uuid.UUID]] = mapped_column( GUID(), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True ) - - # Acci├│n realizada (ej: "user.login", "ticket.create", "ticket.assign") + + # Acción realizada en formato "recurso.verbo" + # Ejemplos: "user.login", "ticket.create", "ticket.assign" action: Mapped[str] = mapped_column( String(100), nullable=False, index=True ) - + # Tipo de recurso afectado (user, ticket, comment, category, etc.) resource_type: Mapped[str] = mapped_column( String(50), nullable=False, index=True ) - + # ID del recurso afectado resource_id: Mapped[Optional[uuid.UUID]] = mapped_column( GUID(), nullable=True ) - - # Contexto de la request + + # Contexto de la request: IP y navegador del usuario ip_address: Mapped[Optional[str]] = mapped_column( String(45).with_variant(INET, "postgresql"), nullable=True, ) user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - - # Correlation ID para rastrear requests relacionadas + + # Correlation ID para rastrear todas las requests relacionadas + # en una misma operación o sesión correlation_id: Mapped[Optional[uuid.UUID]] = mapped_column( GUID(), nullable=True, index=True ) - - # Valores antes del cambio (JSON) + + # Estado del recurso antes del cambio (para auditoría de cambios) old_values: Mapped[Optional[Dict[str, Any]]] = mapped_column( JSON().with_variant(JSONB, "postgresql"), nullable=True ) - - # Valores despu├®s del cambio (JSON) + + # Estado del recurso después del cambio (para auditoría de cambios) new_values: Mapped[Optional[Dict[str, Any]]] = mapped_column( JSON().with_variant(JSONB, "postgresql"), nullable=True ) - - # Metadata adicional (cualquier info relevante) - # Nota: 'metadata' est├í reservado en SQLAlchemy, usamos 'extra_metadata' + + # Metadata adicional con cualquier información relevante del contexto + # Nota: 'metadata' está reservado en SQLAlchemy, se usa 'extra_metadata' + # como nombre del atributo Python, pero la columna en BD se llama 'metadata' extra_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column( - 'metadata', # Nombre real de la columna en BD + 'metadata', JSON().with_variant(JSONB, "postgresql"), nullable=True ) - - # Timestamp + + # Timestamp de creación con timezone + # CORRECCIÓN: default=lambda: datetime.now(timezone.utc) genera un + # datetime aware en UTC, compatible con DateTime(timezone=True). + # El default anterior (datetime.utcnow) generaba datetimes naive, + # causando que los filtros de fecha fallaran silenciosamente porque + # SQLAlchemy no podía comparar aware vs naive correctamente. created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), - default=datetime.utcnow, + default=lambda: datetime.now(timezone.utc), nullable=False, index=True ) - - # Relaciones + + # Relaciones con otros modelos tenant: Mapped["Tenant"] = relationship("Tenant", foreign_keys=[tenant_id]) user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id]) - - # ├ìndices compuestos para queries comunes + + # Índices compuestos para optimizar las queries más frecuentes __table_args__ = ( + # Filtrar logs por tenant y tipo de acción (uso más común) Index('idx_audit_logs_tenant_action', 'tenant_id', 'action'), + # Buscar el historial de un recurso específico Index('idx_audit_logs_resource', 'resource_type', 'resource_id'), + # Ver la actividad de un usuario ordenada por fecha Index('idx_audit_logs_user_created', 'user_id', 'created_at'), ) - - # Configuraci├│n del mapper: excluir updated_at porque audit logs son inmutables + + # Los audit logs son inmutables: nunca se actualizan, solo se crean + # Por eso se excluye updated_at del mapper __mapper_args__ = { "exclude_properties": ["updated_at"] } - + def __repr__(self) -> str: - return f"" - + return ( + f"" + ) + @property def action_display(self) -> str: - """Formato amigable de la acci├│n.""" + """ + Formato legible de la acción para mostrar en la interfaz. + + Convierte el formato interno "recurso.verbo" a texto descriptivo. + Ejemplo: "ticket.create" → "creó ticket" + """ parts = self.action.split('.') if len(parts) == 2: resource, verb = parts verb_map = { - 'create': 'cre├│', - 'update': 'actualiz├│', - 'delete': 'elimin├│', - 'login': 'inici├│ sesi├│n', - 'logout': 'cerr├│ sesi├│n', - 'assign': 'asign├│', - 'close': 'cerr├│', - 'reopen': 'reabri├│' + 'create': 'creó', + 'update': 'actualizó', + 'delete': 'eliminó', + 'login': 'inició sesión', + 'logout': 'cerró sesión', + 'login_failed': 'intentó iniciar sesión', + 'assign': 'asignó', + 'close': 'cerró', + 'reopen': 'reabrió' } return f"{verb_map.get(verb, verb)} {resource}" - return self.action + return self.action \ No newline at end of file diff --git a/frontend-internal/src/lib/components/Sidebar.svelte b/frontend-internal/src/lib/components/Sidebar.svelte index d20d537..47ae2ed 100644 --- a/frontend-internal/src/lib/components/Sidebar.svelte +++ b/frontend-internal/src/lib/components/Sidebar.svelte @@ -46,7 +46,7 @@ ); } - if (role === 'ADMIN') { + if (role === 'ADMIN' || role === 'SUPPORT_MANAGER') { baseNavigation.push( { name: 'SLA Management', @@ -57,7 +57,12 @@ name: 'Reportes', href: '/reports', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' - }, + } + ); + } + + if (role === 'ADMIN') { + baseNavigation.push( { name: 'Auditoría', href: '/audit', diff --git a/frontend-internal/src/routes/audit/+page.svelte b/frontend-internal/src/routes/audit/+page.svelte index 86440fb..a6edb66 100644 --- a/frontend-internal/src/routes/audit/+page.svelte +++ b/frontend-internal/src/routes/audit/+page.svelte @@ -5,7 +5,7 @@ import { api } from '$lib/utils/api'; import { onMount } from 'svelte'; - // Estado de carga y datos + // ─── Estado principal ─────────────────────────────────────────────────────── let logs: any[] = []; let stats: any = null; let users: any[] = []; @@ -18,106 +18,91 @@ let showDetailModal = false; let showIncidentModal = false; - // Paginación + // ─── Paginación de logs ───────────────────────────────────────────────────── let currentPage = 1; let totalPages = 1; let totalLogs = 0; const perPage = 20; - // Paginación de incidentes + // ─── Paginación de incidentes ─────────────────────────────────────────────── let incidentsPage = 1; let incidentsTotalPages = 1; let totalIncidents = 0; const incidentsPerPage = 10; - // Filtros básicos + // ─── Filtros de logs ──────────────────────────────────────────────────────── let filterUserId = ''; let filterAction = ''; let filterResourceType = ''; let searchText = ''; + let showAdvancedFilters = false; - // Filtros de incidentes + // ─── Filtros de incidentes ────────────────────────────────────────────────── let filterSeverity = ''; let filterIncidentType = ''; let filterStatus = ''; let incidentSearchText = ''; - // Filtro multi-tenant (solo para ADMIN/SUPPORT_MANAGER) + // ─── Multi-tenant ─────────────────────────────────────────────────────────── let allTenants = false; - // Filtro de período + // ─── Período de consulta ──────────────────────────────────────────────────── let periodFilter: 'today' | 'yesterday' | 'last7days' | 'last30days' | 'custom' = 'today'; let customDateFrom = ''; let customDateTo = ''; - // Control de visibilidad de filtros avanzados - let showAdvancedFilters = false; + // ─── Conjuntos para selectores dinámicos ─────────────────────────────────── + let availableActions = new Set(); + let availableResourceTypes = new Set(); - // Usuario actual + // ─── Reactivos ────────────────────────────────────────────────────────────── $: currentUser = $auth.user; $: canSeeAllTenants = currentUser && (currentUser.role === 'ADMIN' || currentUser.role === 'SUPPORT_MANAGER'); - // Contador de filtros activos (excluyendo el período que es por defecto) $: activeFiltersCount = [filterUserId, filterAction, filterResourceType, searchText].filter( f => f && f.trim() ).length; - // Configuración de tarjetas estadísticas $: statsCards = [ { label: 'Total de Registros', value: stats?.total_actions, - icon: 'clipboard', color: 'gray', - desc: 'Todas las acciones registradas' + desc: 'Historial completo del sistema' }, { label: 'Actividad Hoy', value: stats?.actions_today, - icon: 'zap', color: 'blue', - desc: 'Registros del día actual' + desc: 'Últimas 24 horas' }, { label: 'Esta Semana', value: stats?.actions_this_week, - icon: 'calendar', color: 'blue', - desc: 'Últimos 7 días de actividad' + desc: 'Últimos 7 días' }, { label: 'Incidentes Críticos', value: stats?.critical_actions_today || 0, - icon: 'alert', color: 'red', desc: 'Requieren atención inmediata', action: true } ]; - // Configuración de botones de período - const periodButtons: Array<{ - id: 'today' | 'yesterday' | 'last7days' | 'last30days' | 'custom'; - label: string; - icon?: boolean; - }> = [ - { id: 'today', label: 'Hoy' }, - { id: 'yesterday', label: 'Ayer' }, - { id: 'last7days', label: 'Últimos 7 días' }, - { id: 'last30days', label: 'Últimos 30 días' }, - { id: 'custom', label: 'Personalizado', icon: true } + const periodButtons = [ + { id: 'today' as const, label: 'Hoy' }, + { id: 'yesterday' as const, label: 'Ayer' }, + { id: 'last7days' as const, label: 'Últimos 7 días' }, + { id: 'last30days' as const, label: 'Últimos 30 días' }, + { id: 'custom' as const, label: 'Personalizado' } ]; - // Tipos de acciones y recursos (extraídos de los logs) - let availableActions = new Set(); - let availableResourceTypes = new Set(); + // ─── Utilidades de fecha ──────────────────────────────────────────────────── - /** - * Obtener fechas según el período seleccionado - */ function getDateRangeForPeriod(): { from: string; to: string } { - // Trabajar en UTC para evitar problemas de zona horaria const now = new Date(); let from: Date; @@ -125,46 +110,36 @@ switch (periodFilter) { case 'today': - // Hoy desde las 00:00:00 hasta ahora en UTC from = new Date( Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0) ); - to = new Date(); // Ahora en UTC + to = new Date(); break; case 'yesterday': - // Ayer completo en UTC from = new Date( Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 1, 0, 0, 0) ); to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0)); break; case 'last7days': - // Últimos 7 días en UTC from = new Date( Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 7, 0, 0, 0) ); to = new Date(); break; case 'last30days': - // Últimos 30 días en UTC from = new Date( Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 30, 0, 0, 0) ); to = new Date(); break; case 'custom': - // Para fechas custom, parsear como UTC - if (!customDateFrom || !customDateTo) { - return { from: '', to: '' }; - } - const fromParts = customDateFrom.split('-').map(Number); - const toParts = customDateTo.split('-').map(Number); - from = new Date(Date.UTC(fromParts[0], fromParts[1] - 1, fromParts[2], 0, 0, 0)); - to = new Date(Date.UTC(toParts[0], toParts[1] - 1, toParts[2], 23, 59, 59)); - return { - from: from.toISOString(), - to: to.toISOString() - }; + if (!customDateFrom || !customDateTo) return { from: '', to: '' }; + const fp = customDateFrom.split('-').map(Number); + const tp = customDateTo.split('-').map(Number); + from = new Date(Date.UTC(fp[0], fp[1] - 1, fp[2], 0, 0, 0)); + to = new Date(Date.UTC(tp[0], tp[1] - 1, tp[2], 23, 59, 59)); + return { from: from.toISOString(), to: to.toISOString() }; default: from = new Date( Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0) @@ -172,65 +147,183 @@ to = new Date(); } - return { - from: from.toISOString(), - to: to.toISOString() + return { from: from.toISOString(), to: to.toISOString() }; + } + + function formatDate(dateString: string): string { + return new Date(dateString).toLocaleString('es-MX', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + + function formatDateShort(dateString: string): string { + const date = new Date(dateString); + const isToday = date.toDateString() === new Date().toDateString(); + return isToday + ? date.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' }) + : date.toLocaleDateString('es-MX', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + + function formatSimpleDate(dateString: string): string { + return new Date(dateString).toLocaleDateString('es-MX', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + + // ─── Colores ──────────────────────────────────────────────────────────────── + + function getActionColor(action: string): string { + if (action.includes('delete')) return 'bg-red-50 text-red-700 border border-red-200'; + if (action.includes('create')) return 'bg-green-50 text-green-700 border border-green-200'; + if (action.includes('update')) return 'bg-blue-50 text-blue-700 border border-blue-200'; + if (action.includes('login') || action.includes('logout')) + return 'bg-gray-100 text-gray-700 border border-gray-300'; + return 'bg-gray-50 text-gray-600 border border-gray-200'; + } + + function getSeverityColor(severity: string): string { + switch (severity?.toLowerCase()) { + case 'critical': + return 'bg-red-50 text-red-700 border border-red-200'; + case 'high': + return 'bg-orange-50 text-orange-700 border border-orange-200'; + case 'medium': + return 'bg-yellow-50 text-yellow-800 border border-yellow-200'; + case 'low': + return 'bg-blue-50 text-blue-700 border border-blue-200'; + default: + return 'bg-gray-50 text-gray-600 border border-gray-200'; + } + } + + function getSeverityLabel(severity: string): string { + const map: Record = { + critical: 'Crítico', + high: 'Alto', + medium: 'Medio', + low: 'Bajo' }; + return map[severity?.toLowerCase()] ?? severity ?? ''; } - /** - * Cambiar período y actualizar datos - */ - function changePeriod(period: typeof periodFilter) { - periodFilter = period; - currentPage = 1; - loadLogs(); + function getStatusColor(status: string): string { + switch (status?.toLowerCase()) { + case 'active': + return 'bg-blue-50 text-blue-700 border border-blue-200'; + case 'resolved': + return 'bg-green-50 text-green-700 border border-green-200'; + case 'investigating': + return 'bg-yellow-50 text-yellow-800 border border-yellow-200'; + default: + return 'bg-gray-50 text-gray-600 border border-gray-200'; + } } - /** - * Cargar estadísticas de auditoría - */ + function getStatusLabel(status: string): string { + const map: Record = { + active: 'Activo', + resolved: 'Resuelto', + investigating: 'Investigando' + }; + return map[status?.toLowerCase()] ?? status ?? ''; + } + + // ─── Textos legibles ──────────────────────────────────────────────────────── + + function formatActionText(action: string): string { + const parts = action.split('.'); + if (parts.length !== 2) return action; + const [resource, verb] = parts; + const verbMap: Record = { + login: 'Inicio de sesión', + logout: 'Cierre de sesión', + login_failed: 'Intento de acceso fallido', + create: 'Creó', + update: 'Actualizó', + delete: 'Eliminó', + assign: 'Asignó', + close: 'Cerró', + reopen: 'Reabrió' + }; + return `${verbMap[verb] ?? verb} · ${resource}`; + } + + function getRoleText(role: string): string { + const map: Record = { + ADMIN: 'Administrador', + SUPPORT_MANAGER: 'Gerente de Soporte', + AGENT: 'Agente', + AUDITOR: 'Auditor', + CLIENT_ADMIN: 'Admin. Cliente', + CLIENT_USER: 'Usuario' + }; + return map[role] ?? role; + } + + function getRiskLabel(level: string): string { + const map: Record = { + critical: 'Crítico', + high: 'Alto', + medium: 'Medio', + low: 'Bajo' + }; + return map[level] ?? level ?? ''; + } + + function getRiskColor(level: string): string { + switch (level) { + case 'critical': + return 'bg-red-50 border border-red-200 text-red-700'; + case 'high': + return 'bg-orange-50 border border-orange-200 text-orange-700'; + case 'medium': + return 'bg-yellow-50 border border-yellow-200 text-yellow-800'; + default: + return 'bg-green-50 border border-green-200 text-green-700'; + } + } + + // ─── Carga de datos ───────────────────────────────────────────────────────── + async function loadStats() { try { const params: any = {}; - if (allTenants && canSeeAllTenants) { - params.all_tenants = true; - } + if (allTenants && canSeeAllTenants) params.all_tenants = true; stats = await api.get('/audit/stats', params); } catch (e) { console.error('Error cargando estadísticas:', e); } } - /** - * Cargar incidentes de seguridad - */ async function loadIncidents() { isLoadingIncidents = true; try { - const params: any = { - page: incidentsPage, - per_page: incidentsPerPage - }; - - // Aplicar filtros de incidentes + const params: any = { page: incidentsPage, per_page: incidentsPerPage }; if (filterSeverity) params.severity = filterSeverity; if (filterIncidentType) params.type = filterIncidentType; if (filterStatus) params.status = filterStatus; if (incidentSearchText) params.search = incidentSearchText; - - // Aplicar filtro multi-tenant si el usuario tiene permiso - if (allTenants && canSeeAllTenants) { - params.all_tenants = true; - } + if (allTenants && canSeeAllTenants) params.all_tenants = true; const response: any = await api.get('/audit/security/incidents', params); - - incidents = response.incidents || []; - totalIncidents = response.total || 0; - incidentsTotalPages = response.total_pages || 1; - incidentsPage = response.page || 1; - } catch (e: any) { + incidents = response.incidents ?? []; + totalIncidents = response.total ?? 0; + incidentsTotalPages = response.total_pages ?? 1; + incidentsPage = response.page ?? 1; + } catch (e) { console.error('Error cargando incidentes:', e); incidents = []; } finally { @@ -238,94 +331,69 @@ } } - /** - * Cargar análisis de seguridad - */ async function loadSecurityAnalysis() { try { const params: any = { hours: 24 }; - if (allTenants && canSeeAllTenants) { - params.all_tenants = true; - } + if (allTenants && canSeeAllTenants) params.all_tenants = true; securityAnalysis = await api.get('/audit/security/analysis', params); - } catch (e: any) { + } catch (e) { console.error('Error cargando análisis de seguridad:', e); } } - /** - * Cargar logs de auditoría con filtros - */ async function loadLogs() { isLoading = true; try { - const params: any = { - page: currentPage, - per_page: perPage - }; - - // Aplicar rango de fechas según período + const params: any = { page: currentPage, per_page: perPage }; const dateRange = getDateRangeForPeriod(); if (dateRange.from) params.date_from = dateRange.from; if (dateRange.to) params.date_to = dateRange.to; - - // Aplicar filtros adicionales if (filterUserId) params.user_id = filterUserId; if (filterAction) params.action = filterAction; if (filterResourceType) params.resource_type = filterResourceType; if (searchText) params.search = searchText; - - // Aplicar filtro multi-tenant si el usuario tiene permiso - if (allTenants && canSeeAllTenants) { - params.all_tenants = true; - } + if (allTenants && canSeeAllTenants) params.all_tenants = true; const response: any = await api.get('/audit/', params); - logs = response.logs; totalLogs = response.total; totalPages = response.total_pages; currentPage = response.page; - // Extraer acciones y tipos de recursos únicos para los selectores logs.forEach((log: any) => { availableActions.add(log.action); availableResourceTypes.add(log.resource_type); }); - - // Convertir Sets a Arrays para bind:value availableActions = new Set(availableActions); availableResourceTypes = new Set(availableResourceTypes); } catch (e: any) { - toast.error('Error cargando logs: ' + (e.message || 'Error desconocido')); + toast.error('Error cargando registros: ' + (e.message ?? 'Error desconocido')); } finally { isLoading = false; } } - /** - * Cargar usuarios para el filtro - */ async function loadUsers() { try { users = (await api.get('/users/')) as any[]; - } catch (e: any) { - console.error('Error cargando usuarios:', e); + } catch (e) { users = []; } } - /** - * Aplicar filtros y recargar desde página 1 - */ + // ─── Acciones del usuario ─────────────────────────────────────────────────── + + function changePeriod(period: typeof periodFilter) { + periodFilter = period; + currentPage = 1; + loadLogs(); + } + function applyFilters() { currentPage = 1; loadLogs(); } - /** - * Limpiar todos los filtros (excepto el período) - */ function clearFilters() { filterUserId = ''; filterAction = ''; @@ -335,190 +403,11 @@ loadLogs(); } - /** - * Filtrar por acciones críticas (vulnerabilidad) - */ - function filterCriticalActions() { - // Aplicar filtro de severidad crítica en incidentes - filterSeverity = 'critical'; - filterStatus = ''; - filterIncidentType = ''; - incidentSearchText = ''; - - // Recargar incidentes con el filtro - incidentsPage = 1; - loadIncidents(); - - // Hacer scroll suave a la sección de incidentes - setTimeout(() => { - const incidentsSection = document.getElementById('incidents-section'); - if (incidentsSection) { - incidentsSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }, 100); - } - - /** - * Cambiar página - */ - function goToPage(page: number) { - if (page >= 1 && page <= totalPages) { - currentPage = page; - loadLogs(); - } - } - - /** - * Ver detalle de un log - */ - function viewDetail(log: any) { - selectedLog = log; - showDetailModal = true; - } - - /** - * Formatear fecha de manera amigable - */ - function formatDate(dateString: string): string { - const date = new Date(dateString); - return date.toLocaleString('es-MX', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - } - - /** - * Formatear fecha corta (solo hora para hoy) - */ - function formatDateShort(dateString: string): string { - const date = new Date(dateString); - const today = new Date(); - const isToday = date.toDateString() === today.toDateString(); - - if (isToday) { - return date.toLocaleTimeString('es-MX', { - hour: '2-digit', - minute: '2-digit' - }); - } - - return date.toLocaleDateString('es-MX', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - } - - /** - * Obtener color de badge según tipo de acción - */ - function getActionColor(action: string): string { - if (action.includes('delete')) return 'bg-gray-50 text-red-700 border border-red-200'; - if (action.includes('update')) return 'bg-gray-50 text-blue-700 border border-blue-200'; - if (action.includes('login') || action.includes('logout')) - return 'bg-gray-50 text-blue-700 border border-blue-200'; - if (action.includes('create')) return 'bg-gray-50 text-green-700 border border-green-200'; - return 'bg-gray-50 text-gray-700 border border-gray-200'; - } - - /** - * Obtener color de severidad - */ - function getSeverityColor(severity: string): string { - switch (severity?.toLowerCase()) { - case 'critical': - return 'bg-gray-50 text-red-700 border border-red-200'; - case 'high': - return 'bg-gray-50 text-orange-700 border border-orange-200'; - case 'medium': - return 'bg-gray-50 text-yellow-800 border border-yellow-200'; - case 'low': - return 'bg-gray-50 text-blue-700 border border-blue-200'; - default: - return 'bg-gray-50 text-gray-700 border border-gray-200'; - } - } - - /** - * Obtener color de estado - */ - function getStatusColor(status: string): string { - switch (status?.toLowerCase()) { - case 'active': - case 'open': - return 'bg-gray-50 text-blue-700 border border-blue-200'; - case 'resolved': - case 'closed': - return 'bg-gray-50 text-green-700 border border-green-200'; - case 'investigating': - return 'bg-gray-50 text-yellow-800 border border-yellow-200'; - default: - return 'bg-gray-50 text-gray-700 border border-gray-200'; - } - } - - /** - * Formatear acción de forma legible - */ - function formatActionText(action: string): string { - const parts = action.split('.'); - if (parts.length !== 2) return action; - - const [resource, verb] = parts; - - const verbMap: Record = { - login: 'Inicio de sesión', - logout: 'Cierre de sesión', - create: 'Creó', - update: 'Actualizó', - delete: 'Eliminó', - assign: 'Asignó', - close: 'Cerró', - reopen: 'Reabrió' - }; - - const verbText = verbMap[verb] || verb; - return `${verbText} ${resource}`; - } - - /** - * Obtener texto del rol - */ - function getRoleText(role: string): string { - const roleMap: Record = { - ADMIN: 'Administrador', - SUPPORT_MANAGER: 'Gerente', - AGENT: 'Agente', - AUDITOR: 'Auditor', - CLIENT_ADMIN: 'Admin Cliente', - CLIENT_USER: 'Usuario' - }; - return roleMap[role] || role; - } - - /** - * Ver detalle de un incidente - */ - function viewIncidentDetail(incident: any) { - selectedIncident = incident; - showIncidentModal = true; - } - - /** - * Aplicar filtros de incidentes y recargar desde página 1 - */ function applyIncidentFilters() { incidentsPage = 1; loadIncidents(); } - /** - * Limpiar filtros de incidentes - */ function clearIncidentFilters() { filterSeverity = ''; filterIncidentType = ''; @@ -528,9 +417,13 @@ loadIncidents(); } - /** - * Cambiar página de incidentes - */ + function goToPage(page: number) { + if (page >= 1 && page <= totalPages) { + currentPage = page; + loadLogs(); + } + } + function goToIncidentsPage(page: number) { if (page >= 1 && page <= incidentsTotalPages) { incidentsPage = page; @@ -538,23 +431,30 @@ } } - /** - * Formatear fecha simple - */ - function formatSimpleDate(dateString: string): string { - const date = new Date(dateString); - return date.toLocaleDateString('es-MX', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); + function viewDetail(log: any) { + selectedLog = log; + showDetailModal = true; + } + function viewIncidentDetail(inc: any) { + selectedIncident = inc; + showIncidentModal = true; } - /** - * Inicializar datos - */ + function filterCriticalActions() { + filterSeverity = 'critical'; + filterStatus = ''; + filterIncidentType = ''; + incidentSearchText = ''; + incidentsPage = 1; + loadIncidents(); + setTimeout(() => { + document + .getElementById('incidents-section') + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 100); + } + + // ─── Inicialización ───────────────────────────────────────────────────────── onMount(() => { loadStats(); loadUsers(); @@ -564,28 +464,33 @@ }); -
- -
-

Auditoría y Seguridad

-

- Monitoreo de actividades, análisis de seguridad e incidentes críticos + +

+ +
+

Auditoría y Seguridad

+

+ Registro completo de actividad, análisis de amenazas e incidentes detectados

- -
- -
-

Período de Consulta

+ +
+ +
+

+ Período de consulta +

{#each periodButtons as btn} @@ -593,9 +498,9 @@
{#if periodFilter === 'custom'} -
+
-
-
{/if}
- + {#if securityAnalysis} -
-
-
-

Análisis de Seguridad

-

Panel especializado de amenazas

-
+
+
+

+ Análisis de seguridad +

+

Últimas 24 horas

-
- {securityAnalysis.overall_risk_level} -
+ {getRiskLabel(securityAnalysis.overall_risk_level)} +
-
-
-
+
+
+
{securityAnalysis.failed_login_attempts}
-

Intentos fallidos

+

Fallos de acceso

-
-
+
+
{securityAnalysis.suspicious_ips_count}
-

IPs sospechosas

+

IPs sospechosas

-
-
- {securityAnalysis.total_threats_detected || 0} +
+
+ {securityAnalysis.total_threats_detected ?? 0}
-

Amenazas

+

Amenazas

-
- Ver análisis completo -
+

+ Ver panel completo de amenazas +

{/if}
- + {#if canSeeAllTenants} -
-

Alcance de Visualización

-
-
- +
+

+ Alcance de visualización +

+
+
{/if} - + {#if stats} -
-

Resumen de Actividad

-
+
+

+ Resumen de actividad +

+
{#each statsCards as card}
-
+

+ {card.label} +

+ +
{card.label} -
-
-
- {card.value?.toLocaleString() || 0} -
+ {card.value?.toLocaleString() ?? 0} + + {#if card.action} {/if}
+

{card.desc}

@@ -764,50 +670,51 @@
{/if} - -
+ +
{#if showAdvancedFilters} -
-
+
+
- +
- + - + {#each Array.from(availableActions).sort() as action} {/each} @@ -830,18 +739,18 @@
- Tipo de recurso
@@ -851,7 +760,7 @@
@@ -861,36 +770,35 @@ {/if}
- -
-

Incidentes de Seguridad

-
-
-
-
- Eventos de seguridad detectados -
-
- {totalIncidents} incidentes -
-
+ +
+
+
+

Incidentes de seguridad

+

Eventos detectados en los últimos 7 días

+ + {totalIncidents} incidente{totalIncidents !== 1 ? 's' : ''} + +
-
-
+
+ +
+
@@ -911,498 +819,445 @@
-
- {#if isLoadingIncidents} -
-
- Cargando incidentes... -
- {:else if incidents.length === 0} -
-

No hay incidentes

-

- No se encontraron incidentes de seguridad para los filtros seleccionados. -

-
- {:else} -
- {#each incidents as incident (incident.id)} + + {#if isLoadingIncidents} +
+
+ Cargando incidentes... +
+ {:else if incidents.length === 0} +
+

Sin incidentes detectados

+

+ No se encontraron eventos para los filtros seleccionados. +

+
+ {:else} +
+ {#each incidents as incident (incident.id)} + + {/each} +
+ + + {#if incidentsTotalPages > 1} +
+ + Página {incidentsPage} de {incidentsTotalPages} + +
+ - {/each} -
- - {#if incidentsTotalPages > 1} -
-
- Página {incidentsPage} de - {incidentsTotalPages} -
-
- - -
- {/if} +
{/if} + {/if} +
+
+ + +
+
+
+

Registros de auditoría

+

+ {#if periodFilter === 'today'} + Actividad de hoy + {:else if periodFilter === 'yesterday'} + Actividad de ayer + {:else if periodFilter === 'last7days'} + Últimos 7 días + {:else if periodFilter === 'last30days'} + Últimos 30 días + {:else} + Período personalizado + {/if} +

+
+
+ + {totalLogs} registro{totalLogs !== 1 ? 's' : ''} + +
- -
-

Registros de Auditoría

-
-
-
-
- Historial completo de actividades - {totalLogs} registros -
- -
+
+ {#if isLoading} +
+
+ Cargando registros...
- - {#if isLoading} -
-
-
-

Cargando registros...

-
-
- {:else if logs.length === 0} -
-
-

No hay registros

-

- {periodFilter === 'today' - ? 'No hay actividad registrada hoy.' - : 'No se encontraron registros para el período seleccionado.'} -

-
-
- {:else} -
- - -
- {#each logs as log (log.id)} - + {/each} +
+ + + {#if totalPages > 1} +
+ + Mostrando + {(currentPage - 1) * perPage + 1}–{Math.min( + currentPage * perPage, + totalLogs + )} + de {totalLogs} + +
+ + + {#if currentPage < totalPages} + + {/if} +
- - {#if totalPages > 1} -
-
- - - Página {currentPage} / {totalPages} - - -
- - -
- {/if} {/if} -
+ {/if}
- + {#if showIncidentModal && selectedIncident} (showIncidentModal = false)} > -
-
-

Información General

-
-
-
Título:
-
{selectedIncident.title}
-
-
-
Severidad:
-
- - {selectedIncident.severity?.toUpperCase()} - -
-
-
-
Estado:
-
- - {selectedIncident.status?.toUpperCase()} - -
-
-
-
Fecha:
-
{formatDate(selectedIncident.created_at)}
-
- {#if selectedIncident.affected_user} -
-
Usuario Afectado:
-
{selectedIncident.affected_user}
-
+
+ +
+
+

{selectedIncident.title}

+ {#if selectedIncident.description} +

{selectedIncident.description}

{/if} - {#if selectedIncident.source_ip} -
-
IP Origen:
-
{selectedIncident.source_ip}
-
- {/if} -
+
+
+ + {getSeverityLabel(selectedIncident.severity)} + + + {getStatusLabel(selectedIncident.status)} + +
- {#if selectedIncident.description} + +
-

Descripción

-
- {selectedIncident.description} -
-
- {/if} - - {#if selectedIncident.evidence && selectedIncident.evidence.length > 0} -
-

Evidencia

-
-
    - {#each selectedIncident.evidence as evidence} -
  • {evidence}
  • - {/each} -
+

Fecha

+

{formatDate(selectedIncident.created_at)}

+
+ {#if selectedIncident.affected_user} +
+

+ Usuario afectado +

+

{selectedIncident.affected_user}

+ {/if} + {#if selectedIncident.source_ip} +
+

+ IP de origen +

+

{selectedIncident.source_ip}

+
+ {/if} +
+ + + {#if selectedIncident.evidence?.length > 0} +
+

Evidencia

+
    + {#each selectedIncident.evidence as item} +
  • + + {item} +
  • + {/each} +
{/if} + {#if selectedIncident.metadata && Object.keys(selectedIncident.metadata).length > 0}
-

Información Adicional

+

+ Información adicional +

{JSON.stringify(
               selectedIncident.metadata,
@@ -1416,7 +1271,7 @@
     
@@ -1424,87 +1279,96 @@ {/if} -{#if showDetailModal && selectedLog}} + +{#if showDetailModal && selectedLog} (showDetailModal = false)} > -
-
-

Información General

-
-
-
Fecha y Hora:
-
{formatDate(selectedLog.created_at)}
-
-
-
Usuario:
-
{selectedLog.user_name || 'Sistema'}
-
-
-
Email:
-
{selectedLog.user_email || 'N/A'}
-
- {#if selectedLog.user_role} -
-
Rol:
-
{getRoleText(selectedLog.user_role)}
-
- {/if} -
-
IP:
-
{selectedLog.ip_address || 'N/A'}
-
-
-
Correlation ID:
-
- {selectedLog.correlation_id || 'N/A'} -
-
-
+
+ +
+ + {selectedLog.action} + + {formatActionText(selectedLog.action)}
-
-

Acción

-
- - {selectedLog.action} - -

{formatActionText(selectedLog.action)}

+ +
+
+

+ Fecha y hora +

+

{formatDate(selectedLog.created_at)}

-
- -
-

Recurso Afectado

-
-
Tipo: {selectedLog.resource_type}
- {#if selectedLog.resource_id} -
- ID: - {selectedLog.resource_id} -
+
+

Usuario

+

{selectedLog.user_name ?? 'Sistema'}

+ {#if selectedLog.user_email} +

{selectedLog.user_email}

{/if}
+ {#if selectedLog.user_role} +
+

Rol

+

{getRoleText(selectedLog.user_role)}

+
+ {/if} +
+

+ Dirección IP +

+

{selectedLog.ip_address ?? 'N/A'}

+
+
+

+ Tipo de recurso +

+

{selectedLog.resource_type}

+
+ {#if selectedLog.resource_id} +
+

+ ID del recurso +

+

{selectedLog.resource_id}

+
+ {/if} + {#if selectedLog.correlation_id} +
+

+ Correlation ID +

+

{selectedLog.correlation_id}

+
+ {/if}
+ {#if selectedLog.user_agent}
-

Navegador / Dispositivo

+

+ Navegador / Dispositivo +

{selectedLog.user_agent}
{/if} + {#if selectedLog.old_values && Object.keys(selectedLog.old_values).length > 0}
-

Valores Anteriores

+

+ Valores anteriores +

{JSON.stringify(
               selectedLog.old_values,
@@ -1514,11 +1378,14 @@
         
{/if} + {#if selectedLog.new_values && Object.keys(selectedLog.new_values).length > 0}
-

Valores Nuevos

+

+ Valores nuevos +

{JSON.stringify(
+            class="bg-green-50 rounded-lg p-3 text-xs font-mono text-gray-600 overflow-auto max-h-40">{JSON.stringify(
               selectedLog.new_values,
               null,
               2
@@ -1526,9 +1393,12 @@
         
{/if} + {#if selectedLog.metadata && Object.keys(selectedLog.metadata).length > 0}
-

Información Adicional

+

+ Información adicional +

{JSON.stringify(
               selectedLog.metadata,
@@ -1542,7 +1412,7 @@
     
diff --git a/frontend-internal/src/routes/reports/+page.svelte b/frontend-internal/src/routes/reports/+page.svelte new file mode 100644 index 0000000..6305d11 --- /dev/null +++ b/frontend-internal/src/routes/reports/+page.svelte @@ -0,0 +1,661 @@ + + + +
+ + +
+
+

Reportes

+

Estadísticas y métricas del sistema de soporte

+
+
+ + + +
+
+ + +
+ +
+ + + {#if isLoading} +
+ + + + + Cargando reporte... +
+ + + {:else if activeTab === 'summary' && summary} +
+
+

Total tickets

+

{summary.total_tickets}

+ {#if summary.tickets_change_pct != null} + {@const cp = changePct(summary.tickets_change_pct, 'tickets')} +

{cp.txt} vs período anterior

+ {/if} +
+
+

Abiertos

+

{summary.open_tickets}

+

+ {summary.total_tickets > 0 ? Math.round(summary.open_tickets / summary.total_tickets * 100) : 0}% del total +

+
+
+

Resueltos

+

{summary.resolved_tickets}

+ {#if summary.resolution_change_pct != null} + {@const cp = changePct(summary.resolution_change_pct, 'resolution')} +

{cp.txt} tasa vs anterior

+ {/if} +
+
+

Urgentes

+

{summary.by_priority.urgent}

+

Tiempo prom: {fmtHours(summary.avg_resolution_hours)}

+
+
+ +
+
+

Tiempo prom. resolución

+

{fmtHours(summary.avg_resolution_hours)}

+

Primera resp: {fmtHours(summary.avg_first_response_hours)}

+
+
+

Satisfacción (CSAT)

+ {#if summary.avg_rating} +

{summary.avg_rating.toFixed(1)}

+

{summary.total_rated} calificaciones

+ {:else} +

Sin calificaciones

+ {/if} +
+
+

Por estado

+
+ {#each Object.entries(summary.by_status).filter(([k]) => k !== 'total') as [s, count]} + {#if count > 0} +
+ + {statusLabel(s.toUpperCase())} + + {count} +
+ {/if} + {/each} +
+
+
+

Por prioridad

+
+ {#each [['URGENT', summary.by_priority.urgent], ['HIGH', summary.by_priority.high], ['MEDIUM', summary.by_priority.medium], ['LOW', summary.by_priority.low]] as [p, cnt]} + {#if cnt > 0} +
+ + {priorityLabel(String(p))} + +
+
+
+
+ {cnt} +
+ {/if} + {/each} +
+
+
+ + + {:else if activeTab === 'agents' && agentReport} +
+
+

Rendimiento por agente — {agentReport.total_agents} agentes

+
+ {#if agentReport.agents.length === 0} +

No hay datos de agentes en este período.

+ {:else} +
+ + + + + + + + + + + + + + + {#each agentReport.agents as a} + + + + + + + + + + + {/each} + +
AgenteAsignadosResueltosAbiertosResolución %Tiempo prom.CSATUrgentes
+
{a.agent_name}
+
{a.agent_email}
+
{a.total_assigned}{a.resolved}{a.open} +
+
+
+
+ {a.resolution_rate}% +
+
{fmtHours(a.avg_resolution_hours)} + {#if a.avg_rating} + {a.avg_rating.toFixed(1)} ★ +
{a.total_rated} cal.
+ {:else} + + {/if} +
+ {#if a.urgent_handled > 0} + {a.urgent_handled} + {:else} + + {/if} +
+
+ {/if} +
+ + + {:else if activeTab === 'categories' && catReport} +
+
+

Tickets por categoría

+ {#if catReport.uncategorized_count > 0} + + + {catReport.uncategorized_count} sin categoría + + {/if} +
+ {#if catReport.categories.length === 0} +

No hay datos de categorías en este período.

+ {:else} +
+ + + + + + + + + + + + + + {#each catReport.categories as cat} + + + + + + + + + + {/each} + +
CategoríaTotalAbiertosResueltosTiempo prom.SLA resp/resolCumplimiento SLA
{cat.category_name}{cat.total_tickets}{cat.open_tickets}{cat.resolved_tickets}{fmtHours(cat.avg_resolution_hours)}{cat.sla_response_hours}h / {cat.sla_resolution_hours}h +
+
+
+
+ {cat.sla_compliance_pct}% +
+
+
+ {/if} +
+ + + {:else if activeTab === 'systems' && sysReport} +
+
+

Tickets por sistema afectado

+ {#if sysReport.no_system_count > 0} + + + {sysReport.no_system_count} sin sistema + + {/if} +
+ {#if sysReport.systems.length === 0} +

No hay tickets con sistema asignado en este período.

+ {:else} + {@const maxSys = Math.max(...sysReport.systems.map(s => s.total_tickets), 1)} +
+ + + + + + + + + + + + + + {#each sysReport.systems as sys} + + + + + + + + + + {/each} + +
SistemaTotalAbiertosResueltosUrgentesTiempo prom.Carga de trabajo
{sys.system_name}{sys.total_tickets}{sys.open_tickets}{sys.resolved_tickets} + {#if sys.urgent_tickets > 0} + {sys.urgent_tickets} + {:else} + + {/if} + {fmtHours(sys.avg_resolution_hours)} +
+
+
+
+ {Math.round(sys.total_tickets / maxSys * 100)}% +
+
+
+ {/if} +
+ + + {:else if activeTab === 'clients' && isAdmin && clientReport} +
+
+

Tickets por cliente — {clientReport.total_clients} clientes activos

+
+ {#if clientReport.clients.length === 0} +

No hay datos de clientes en este período.

+ {:else} +
+ + + + + + + + + + + + + + + {#each clientReport.clients as c} + + + + + + + + + + + {/each} + +
ClienteTotalAbiertosResueltosUrgentesTiempo prom.CSATÚltimo ticket
{c.tenant_name}{c.total_tickets}{c.open_tickets}{c.resolved_tickets} + {#if c.urgent_tickets > 0} + {c.urgent_tickets} + {:else} + + {/if} + {fmtHours(c.avg_resolution_hours)} + {c.avg_rating ? c.avg_rating.toFixed(1) + ' ★' : '—'} + + {c.last_ticket_at ? new Date(c.last_ticket_at).toLocaleDateString('es-MX') : '—'} +
+
+ {/if} +
+ + + {:else if activeTab === 'trends' && trendsReport} +
+
+

Tickets diarios — últimos {trendsReport.total_days} días

+
+ Creados + Resueltos +
+
+ {#if trendsReport.data_points.length > 0} + {@const maxVal = maxTrend(trendsReport.data_points)} +
+
+
+ {#each trendsReport.data_points as pt} +
+ {/each} +
+
+ {#each trendsReport.data_points as pt} +
+ {/each} +
+
+
+ {#each trendsReport.data_points as pt, i} +
+ {#if i % 7 === 0} + {fmtDate(pt.date)} + {/if} +
+ {/each} +
+
+
+ + + + + + + + + + + {#each [...trendsReport.data_points].reverse() as pt} + {#if pt.created > 0 || pt.resolved > 0} + + + + + + + {/if} + {/each} + +
FechaCreadosResueltosBalance
{pt.date}{pt.created}{pt.resolved} + {pt.net_open > 0 ? '+' : ''}{pt.net_open} +
+
+ {:else} +

No hay datos en este período.

+ {/if} +
+ + + {:else if activeTab === 'csat' && csatReport} +
+
+

Resumen CSAT

+ {#if csatReport.avg_rating} +
+

{csatReport.avg_rating.toFixed(1)}

+

{stars(csatReport.avg_rating)}

+

{csatReport.total_rated} de {csatReport.total_tickets} tickets calificados

+

{csatReport.response_rate}% tasa de respuesta

+
+
+ {#each [5, 4, 3, 2, 1] as star} + {@const count = csatReport.distribution[`rating_${star}`] ?? 0} + {@const pct = csatReport.total_rated > 0 ? Math.round(count / csatReport.total_rated * 100) : 0} +
+ {star}★ +
+
+
+ {count} +
+ {/each} +
+ {:else} +

Sin calificaciones en este período

+ {/if} +
+
+

CSAT por categoría

+ {#if csatReport.by_category.length} +
+ {#each csatReport.by_category as item} +
+
+ {item.category} + {item.avg_rating ? item.avg_rating.toFixed(1) + ' ★' : '—'} +
+
+
+
+

{item.total_rated} calificaciones

+
+ {/each} +
+ {:else} +

Sin datos

+ {/if} +
+
+

CSAT por agente

+ {#if csatReport.by_agent.length} +
+ {#each csatReport.by_agent as item} +
+
+ {item.agent} + {item.avg_rating ? item.avg_rating.toFixed(1) + ' ★' : '—'} +
+
+
+
+

{item.total_rated} calificaciones

+
+ {/each} +
+ {:else} +

Sin datos

+ {/if} +
+
+ {#if csatReport.recent_comments?.length > 0} +
+

Comentarios recientes

+
+ {#each csatReport.recent_comments as c} +
+ + {'★'.repeat(c.rating)}{'☆'.repeat(5 - c.rating)} + +
+

{c.comment}

+

+ {c.rated_at ? new Date(c.rated_at).toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' }) : ''} +

+
+
+ {/each} +
+
+ {/if} + + + {:else if !isLoading} +
+

Selecciona un período o cambia de pestaña para ver el reporte.

+
+ {/if} + +