import logging from typing import List, Optional from fastapi import APIRouter, HTTPException, Query, UploadFile, File from app.core.database import execute_query from app.services.eset_service import eset_service from psycopg2.extras import Json from datetime import datetime, date import os import uuid logger = logging.getLogger(__name__) router = APIRouter() def _eset_extract_first_str(payload: dict, keys: List[str]) -> Optional[str]: if payload is None: return None key_set = {k.lower() for k in keys} stack = [payload] while stack: current = stack.pop() if isinstance(current, dict): for k, v in current.items(): if k.lower() in key_set and isinstance(v, str) and v.strip(): return v.strip() if isinstance(v, (dict, list)): stack.append(v) elif isinstance(current, list): for item in current: if isinstance(item, (dict, list)): stack.append(item) return None def _eset_extract_group_path(payload: dict) -> Optional[str]: return _eset_extract_first_str(payload, ["parentGroup", "groupPath", "group", "path"]) def _eset_extract_group_name(payload: dict) -> Optional[str]: group_path = _eset_extract_group_path(payload) if group_path and "/" in group_path: name = group_path.split("/")[-1].strip() return name or None return group_path def _eset_extract_company(payload: dict) -> Optional[str]: company = _eset_extract_first_str(payload, ["company", "organization", "tenant", "customer", "userCompany"]) if company: return company group_path = _eset_extract_group_path(payload) if group_path and "/" in group_path: return group_path.split("/")[-1].strip() or None return None def _eset_detect_asset_type(payload: dict) -> str: device_type = _eset_extract_first_str(payload, ["deviceType", "type"]) if device_type: val = device_type.lower() if "server" in val: return "server" if "laptop" in val or "notebook" in val: return "laptop" return "pc" def _match_customer_exact(name: str) -> Optional[int]: if not name: return None result = execute_query("SELECT id FROM customers WHERE LOWER(name) = LOWER(%s)", (name,)) if len(result or []) == 1: return result[0]["id"] return None def _get_contact_customer(contact_id: int) -> Optional[int]: query = """ SELECT customer_id FROM contact_companies WHERE contact_id = %s ORDER BY is_primary DESC, id ASC LIMIT 1 """ result = execute_query(query, (contact_id,)) if result: return result[0]["customer_id"] return None def _upsert_hardware_contact(hardware_id: int, contact_id: int) -> None: query = """ INSERT INTO hardware_contacts (hardware_id, contact_id, role, source) VALUES (%s, %s, %s, %s) ON CONFLICT (hardware_id, contact_id) DO NOTHING """ execute_query(query, (hardware_id, contact_id, "primary", "eset")) # ============================================================================ # CRUD Endpoints for Hardware Assets # ============================================================================ @router.get("/hardware", response_model=List[dict]) async def list_hardware( customer_id: Optional[int] = None, status: Optional[str] = None, asset_type: Optional[str] = None, q: Optional[str] = None ): """List all hardware with optional filters.""" query = "SELECT * FROM hardware_assets WHERE deleted_at IS NULL" params = [] if customer_id: query += " AND current_owner_customer_id = %s" params.append(customer_id) if status: query += " AND status = %s" params.append(status) if asset_type: query += " AND asset_type = %s" params.append(asset_type) if q: query += " AND (serial_number ILIKE %s OR model ILIKE %s OR brand ILIKE %s)" search_param = f"%{q}%" params.extend([search_param, search_param, search_param]) query += " ORDER BY created_at DESC" result = execute_query(query, tuple(params)) logger.info(f"✅ Listed {len(result) if result else 0} hardware assets") return result or [] @router.get("/hardware/by-customer/{customer_id}", response_model=List[dict]) async def list_hardware_by_customer(customer_id: int): """List hardware assets owned by a customer.""" query = """ SELECT * FROM hardware_assets WHERE deleted_at IS NULL AND current_owner_customer_id = %s ORDER BY created_at DESC """ result = execute_query(query, (customer_id,)) return result or [] @router.get("/hardware/by-contact/{contact_id}", response_model=List[dict]) async def list_hardware_by_contact(contact_id: int): """List hardware assets linked directly to a contact.""" query = """ SELECT DISTINCT h.* FROM hardware_assets h JOIN hardware_contacts hc ON hc.hardware_id = h.id WHERE hc.contact_id = %s AND h.deleted_at IS NULL ORDER BY h.created_at DESC """ result = execute_query(query, (contact_id,)) return result or [] @router.post("/hardware", response_model=dict) async def create_hardware(data: dict): """Create a new hardware asset.""" try: query = """ INSERT INTO hardware_assets ( asset_type, brand, model, serial_number, customer_asset_id, internal_asset_id, notes, current_owner_type, current_owner_customer_id, status, status_reason, warranty_until, end_of_life, anydesk_id, anydesk_link, eset_uuid, hardware_specs, eset_group ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING * """ specs = data.get("hardware_specs") if specs: specs = Json(specs) params = ( data.get("asset_type"), data.get("brand"), data.get("model"), data.get("serial_number"), data.get("customer_asset_id"), data.get("internal_asset_id"), data.get("notes"), data.get("current_owner_type", "bmc"), data.get("current_owner_customer_id"), data.get("status", "active"), data.get("status_reason"), data.get("warranty_until"), data.get("end_of_life"), data.get("anydesk_id"), data.get("anydesk_link"), data.get("eset_uuid"), specs, data.get("eset_group") ) result = execute_query(query, params) if not result: raise HTTPException(status_code=500, detail="Failed to create hardware") hardware = result[0] logger.info(f"✅ Created hardware asset: {hardware['id']} - {hardware['brand']} {hardware['model']}") # Create initial ownership record if owner specified if data.get("current_owner_type"): ownership_query = """ INSERT INTO hardware_ownership_history ( hardware_id, owner_type, owner_customer_id, start_date, notes ) VALUES (%s, %s, %s, %s, %s) """ ownership_params = ( hardware['id'], data.get("current_owner_type"), data.get("current_owner_customer_id"), date.today(), "Initial ownership record" ) execute_query(ownership_query, ownership_params) return hardware except Exception as e: logger.error(f"❌ Failed to create hardware: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @router.post("/hardware/quick", response_model=dict) async def quick_create_hardware(data: dict): """Quick create hardware with minimal fields (name + AnyDesk info).""" try: name = (data.get("name") or "").strip() customer_id = data.get("customer_id") anydesk_id = (data.get("anydesk_id") or "").strip() or None anydesk_link = (data.get("anydesk_link") or "").strip() or None if not name: raise HTTPException(status_code=400, detail="Name is required") if not customer_id: raise HTTPException(status_code=400, detail="Customer ID is required") query = """ INSERT INTO hardware_assets ( asset_type, model, current_owner_type, current_owner_customer_id, status, anydesk_id, anydesk_link, notes ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING * """ params = ( "andet", name, "customer", customer_id, "active", anydesk_id, anydesk_link, "Quick created from case/ticket flow", ) result = execute_query(query, params) if not result: raise HTTPException(status_code=500, detail="Failed to create hardware") return result[0] except HTTPException: raise except Exception as e: logger.error(f"❌ Failed to quick-create hardware: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/hardware/{hardware_id}", response_model=dict) async def get_hardware(hardware_id: int): """Get hardware details by ID.""" query = "SELECT * FROM hardware_assets WHERE id = %s AND deleted_at IS NULL" result = execute_query(query, (hardware_id,)) if not result: raise HTTPException(status_code=404, detail="Hardware not found") logger.info(f"✅ Retrieved hardware: {hardware_id}") return result[0] @router.patch("/hardware/{hardware_id}", response_model=dict) async def update_hardware(hardware_id: int, data: dict): """Update hardware asset.""" try: # Build dynamic update query update_fields = [] params = [] allowed_fields = [ "asset_type", "brand", "model", "serial_number", "customer_asset_id", "internal_asset_id", "notes", "current_owner_type", "current_owner_customer_id", "status", "status_reason", "warranty_until", "end_of_life", "follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link", "eset_uuid", "hardware_specs", "eset_group" ] for field in allowed_fields: if field in data: update_fields.append(f"{field} = %s") val = data[field] if field == "hardware_specs" and val: val = Json(val) params.append(val) if not update_fields: raise HTTPException(status_code=400, detail="No valid fields to update") update_fields.append("updated_at = NOW()") params.append(hardware_id) query = f""" UPDATE hardware_assets SET {', '.join(update_fields)} WHERE id = %s AND deleted_at IS NULL RETURNING * """ result = execute_query(query, tuple(params)) if not result: raise HTTPException(status_code=404, detail="Hardware not found") logger.info(f"✅ Updated hardware: {hardware_id}") return result[0] except HTTPException: raise except Exception as e: logger.error(f"❌ Failed to update hardware {hardware_id}: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @router.delete("/hardware/{hardware_id}") async def delete_hardware(hardware_id: int): """Soft-delete hardware asset.""" query = """ UPDATE hardware_assets SET deleted_at = NOW() WHERE id = %s AND deleted_at IS NULL RETURNING id """ result = execute_query(query, (hardware_id,)) if not result: raise HTTPException(status_code=404, detail="Hardware not found") logger.info(f"✅ Deleted hardware: {hardware_id}") return {"message": "Hardware deleted successfully"} # ============================================================================ # Ownership History Endpoints # ============================================================================ @router.get("/hardware/{hardware_id}/ownership", response_model=List[dict]) async def get_ownership_history(hardware_id: int): """Get ownership history for hardware.""" query = """ SELECT * FROM hardware_ownership_history WHERE hardware_id = %s AND deleted_at IS NULL ORDER BY start_date DESC """ result = execute_query(query, (hardware_id,)) logger.info(f"✅ Retrieved ownership history for hardware: {hardware_id}") return result or [] @router.post("/hardware/{hardware_id}/ownership", response_model=dict) async def add_ownership_record(hardware_id: int, data: dict): """Add ownership record (auto-closes previous active ownership).""" try: # Close any active ownership records close_query = """ UPDATE hardware_ownership_history SET end_date = %s WHERE hardware_id = %s AND end_date IS NULL AND deleted_at IS NULL """ execute_query(close_query, (date.today(), hardware_id)) # Create new ownership record insert_query = """ INSERT INTO hardware_ownership_history ( hardware_id, owner_type, owner_customer_id, start_date, notes ) VALUES (%s, %s, %s, %s, %s) RETURNING * """ params = ( hardware_id, data.get("owner_type"), data.get("owner_customer_id"), data.get("start_date", date.today()), data.get("notes") ) result = execute_query(insert_query, params) # Update current owner in hardware_assets update_query = """ UPDATE hardware_assets SET current_owner_type = %s, current_owner_customer_id = %s, updated_at = NOW() WHERE id = %s """ execute_query(update_query, (data.get("owner_type"), data.get("owner_customer_id"), hardware_id)) logger.info(f"✅ Added ownership record for hardware: {hardware_id}") return result[0] except Exception as e: logger.error(f"❌ Failed to add ownership record: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) # ============================================================================ # Location History Endpoints # ============================================================================ @router.get("/hardware/{hardware_id}/locations", response_model=List[dict]) async def get_location_history(hardware_id: int): """Get location history for hardware.""" query = """ SELECT * FROM hardware_location_history WHERE hardware_id = %s AND deleted_at IS NULL ORDER BY start_date DESC """ result = execute_query(query, (hardware_id,)) logger.info(f"✅ Retrieved location history for hardware: {hardware_id}") return result or [] @router.post("/hardware/{hardware_id}/locations", response_model=dict) async def add_location_record(hardware_id: int, data: dict): """Add location record (auto-closes previous active location).""" try: # Close any active location records close_query = """ UPDATE hardware_location_history SET end_date = %s WHERE hardware_id = %s AND end_date IS NULL AND deleted_at IS NULL """ execute_query(close_query, (date.today(), hardware_id)) # Create new location record insert_query = """ INSERT INTO hardware_location_history ( hardware_id, location_id, location_name, start_date, notes ) VALUES (%s, %s, %s, %s, %s) RETURNING * """ params = ( hardware_id, data.get("location_id"), data.get("location_name"), data.get("start_date", date.today()), data.get("notes") ) result = execute_query(insert_query, params) # Update current location in hardware_assets update_query = """ UPDATE hardware_assets SET current_location_id = %s, updated_at = NOW() WHERE id = %s """ execute_query(update_query, (data.get("location_id"), hardware_id)) logger.info(f"✅ Added location record for hardware: {hardware_id}") return result[0] except Exception as e: logger.error(f"❌ Failed to add location record: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) # ============================================================================ # Attachment Endpoints # ============================================================================ @router.get("/hardware/{hardware_id}/attachments", response_model=List[dict]) async def get_attachments(hardware_id: int): """Get all attachments for hardware.""" query = """ SELECT * FROM hardware_attachments WHERE hardware_id = %s AND deleted_at IS NULL ORDER BY uploaded_at DESC """ result = execute_query(query, (hardware_id,)) logger.info(f"✅ Retrieved {len(result) if result else 0} attachments for hardware: {hardware_id}") return result or [] @router.post("/hardware/{hardware_id}/attachments", response_model=dict) async def upload_attachment(hardware_id: int, data: dict): """Upload attachment for hardware.""" try: # Generate storage reference (in production, this would upload to cloud storage) storage_ref = f"hardware/{hardware_id}/{uuid.uuid4()}_{data.get('file_name')}" query = """ INSERT INTO hardware_attachments ( hardware_id, file_type, file_name, storage_ref, file_size_bytes, mime_type, description, uploaded_by_user_id ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING * """ params = ( hardware_id, data.get("file_type", "other"), data.get("file_name"), storage_ref, data.get("file_size_bytes"), data.get("mime_type"), data.get("description"), data.get("uploaded_by_user_id") ) result = execute_query(query, params) logger.info(f"✅ Uploaded attachment for hardware: {hardware_id}") return result[0] except Exception as e: logger.error(f"❌ Failed to upload attachment: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @router.delete("/hardware/{hardware_id}/attachments/{attachment_id}") async def delete_attachment(hardware_id: int, attachment_id: int): """Soft-delete attachment.""" query = """ UPDATE hardware_attachments SET deleted_at = NOW() WHERE id = %s AND hardware_id = %s AND deleted_at IS NULL RETURNING id """ result = execute_query(query, (attachment_id, hardware_id)) if not result: raise HTTPException(status_code=404, detail="Attachment not found") logger.info(f"✅ Deleted attachment {attachment_id} for hardware: {hardware_id}") return {"message": "Attachment deleted successfully"} # ============================================================================ # Case Relations Endpoints # ============================================================================ @router.get("/hardware/{hardware_id}/cases", response_model=List[dict]) async def get_related_cases(hardware_id: int): """Get all cases related to this hardware.""" query = """ SELECT hcr.*, s.titel, s.status, s.customer_id FROM hardware_case_relations hcr LEFT JOIN sag_sager s ON hcr.case_id = s.id WHERE hcr.hardware_id = %s AND hcr.deleted_at IS NULL AND s.deleted_at IS NULL ORDER BY hcr.created_at DESC """ result = execute_query(query, (hardware_id,)) logger.info(f"✅ Retrieved {len(result) if result else 0} related cases for hardware: {hardware_id}") return result or [] @router.post("/hardware/{hardware_id}/cases", response_model=dict) async def link_case(hardware_id: int, data: dict): """Link hardware to a case.""" try: query = """ INSERT INTO hardware_case_relations ( hardware_id, case_id, relation_type ) VALUES (%s, %s, %s) RETURNING * """ params = ( hardware_id, data.get("case_id"), data.get("relation_type", "related") ) result = execute_query(query, params) logger.info(f"✅ Linked hardware {hardware_id} to case {data.get('case_id')}") return result[0] except Exception as e: logger.error(f"❌ Failed to link case: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @router.delete("/hardware/{hardware_id}/cases/{case_id}") async def unlink_case(hardware_id: int, case_id: int): """Unlink hardware from a case.""" query = """ UPDATE hardware_case_relations SET deleted_at = NOW() WHERE hardware_id = %s AND case_id = %s AND deleted_at IS NULL RETURNING id """ result = execute_query(query, (hardware_id, case_id)) if not result: raise HTTPException(status_code=404, detail="Case relation not found") logger.info(f"✅ Unlinked hardware {hardware_id} from case {case_id}") return {"message": "Case unlinked successfully"} # ============================================================================ # Tag Endpoints # ============================================================================ @router.get("/hardware/{hardware_id}/tags", response_model=List[dict]) async def get_tags(hardware_id: int): """Get all tags for hardware.""" query = """ SELECT * FROM hardware_tags WHERE hardware_id = %s AND deleted_at IS NULL ORDER BY created_at DESC """ result = execute_query(query, (hardware_id,)) logger.info(f"✅ Retrieved {len(result) if result else 0} tags for hardware: {hardware_id}") return result or [] @router.post("/hardware/{hardware_id}/tags", response_model=dict) async def add_tag(hardware_id: int, data: dict): """Add tag to hardware.""" try: query = """ INSERT INTO hardware_tags ( hardware_id, tag_name, tag_type ) VALUES (%s, %s, %s) RETURNING * """ params = ( hardware_id, data.get("tag_name"), data.get("tag_type", "manual") ) result = execute_query(query, params) logger.info(f"✅ Added tag '{data.get('tag_name')}' to hardware: {hardware_id}") return result[0] except Exception as e: logger.error(f"❌ Failed to add tag: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @router.delete("/hardware/{hardware_id}/tags/{tag_id}") async def delete_tag(hardware_id: int, tag_id: int): """Delete tag from hardware.""" query = """ UPDATE hardware_tags SET deleted_at = NOW() WHERE id = %s AND hardware_id = %s AND deleted_at IS NULL RETURNING id """ result = execute_query(query, (tag_id, hardware_id)) if not result: raise HTTPException(status_code=404, detail="Tag not found") logger.info(f"✅ Deleted tag {tag_id} from hardware: {hardware_id}") return {"message": "Tag deleted successfully"} # ============================================================================ # Search Endpoint # ============================================================================ @router.get("/search/hardware", response_model=List[dict]) async def search_hardware(q: str = Query(..., min_length=1)): """Search hardware by serial number, model, or brand.""" query = """ SELECT * FROM hardware_assets WHERE deleted_at IS NULL AND ( serial_number ILIKE %s OR model ILIKE %s OR brand ILIKE %s OR customer_asset_id ILIKE %s OR internal_asset_id ILIKE %s ) ORDER BY created_at DESC LIMIT 50 """ search_param = f"%{q}%" result = execute_query(query, (search_param, search_param, search_param, search_param, search_param)) logger.info(f"✅ Search for '{q}' returned {len(result) if result else 0} results") return result or [] @router.post("/hardware/{hardware_id}/sync-eset", response_model=dict) async def sync_eset_data(hardware_id: int, eset_uuid: Optional[str] = Query(None)): """Sync hardware data from ESET.""" # Get current hardware check_query = "SELECT * FROM hardware_assets WHERE id = %s AND deleted_at IS NULL" result = execute_query(check_query, (hardware_id,)) if not result: raise HTTPException(status_code=404, detail="Hardware not found") current = result[0] # Determine UUID uuid_to_use = eset_uuid or current.get("eset_uuid") if not uuid_to_use: raise HTTPException(status_code=400, detail="No ESET UUID provided or found on asset. Please provide 'eset_uuid' query parameter.") # Fetch from ESET details = await eset_service.get_device_details(uuid_to_use) if not details: raise HTTPException(status_code=404, detail="Device not found in ESET") # Update hardware asset update_data = { "eset_uuid": uuid_to_use, "hardware_specs": details } # We can perform the update directly here or call update_hardware if available return await update_hardware(hardware_id, update_data) @router.get("/hardware/eset/test", response_model=dict) async def test_eset_device(device_uuid: str = Query(..., min_length=1)): """Test ESET device lookup by UUID.""" details = await eset_service.get_device_details(device_uuid) if not details: raise HTTPException(status_code=404, detail="Device not found in ESET") return details @router.get("/hardware/eset/devices", response_model=dict) async def list_eset_devices( page_size: Optional[int] = Query(None, ge=1, le=1000), page_token: Optional[str] = Query(None) ): """List devices directly from ESET Device Management.""" payload = await eset_service.list_devices(page_size=page_size, page_token=page_token) if not payload: raise HTTPException(status_code=404, detail="No devices returned from ESET") return payload @router.post("/hardware/eset/import", response_model=dict) async def import_eset_device(data: dict): """Import ESET device into hardware assets and optionally link to contact.""" device_uuid = (data.get("device_uuid") or "").strip() contact_id = data.get("contact_id") if not device_uuid: raise HTTPException(status_code=400, detail="device_uuid is required") details = await eset_service.get_device_details(device_uuid) if not details: raise HTTPException(status_code=404, detail="Device not found in ESET") serial = _eset_extract_first_str(details, ["serialNumber", "serial", "serial_number"]) model = _eset_extract_first_str(details, ["model", "deviceModel", "deviceName", "name"]) brand = _eset_extract_first_str(details, ["manufacturer", "brand", "vendor"]) group_path = _eset_extract_group_path(details) group_name = _eset_extract_group_name(details) company = _eset_extract_company(details) if contact_id: contact_check = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,)) if not contact_check: raise HTTPException(status_code=404, detail="Contact not found") customer_id = _get_contact_customer(contact_id) if contact_id else None if not customer_id: customer_id = _match_customer_exact(group_name or company) owner_type = "customer" if customer_id else "bmc" conditions = ["eset_uuid = %s"] params = [device_uuid] if serial: conditions.append("serial_number = %s") params.append(serial) lookup_query = f"SELECT * FROM hardware_assets WHERE deleted_at IS NULL AND ({' OR '.join(conditions)})" existing = execute_query(lookup_query, tuple(params)) if existing: hardware_id = existing[0]["id"] update_fields = ["eset_uuid = %s", "hardware_specs = %s", "updated_at = NOW()"] update_params = [device_uuid, Json(details)] if group_path: update_fields.append("eset_group = %s") update_params.append(group_path) if not existing[0].get("serial_number") and serial: update_fields.append("serial_number = %s") update_params.append(serial) if not existing[0].get("model") and model: update_fields.append("model = %s") update_params.append(model) if not existing[0].get("brand") and brand: update_fields.append("brand = %s") update_params.append(brand) if customer_id: update_fields.append("current_owner_type = %s") update_params.append("customer") update_fields.append("current_owner_customer_id = %s") update_params.append(customer_id) update_params.append(hardware_id) update_query = f""" UPDATE hardware_assets SET {', '.join(update_fields)} WHERE id = %s RETURNING * """ hardware = execute_query(update_query, tuple(update_params)) hardware = hardware[0] if hardware else None else: insert_query = """ INSERT INTO hardware_assets ( asset_type, brand, model, serial_number, current_owner_type, current_owner_customer_id, notes, eset_uuid, hardware_specs, eset_group ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING * """ insert_params = ( _eset_detect_asset_type(details), brand, model, serial, owner_type, customer_id, "Imported from ESET", device_uuid, Json(details), group_path ) hardware = execute_query(insert_query, insert_params) hardware = hardware[0] if hardware else None if hardware and contact_id: _upsert_hardware_contact(hardware["id"], contact_id) return hardware or {} @router.get("/hardware/eset/matches", response_model=List[dict]) async def list_eset_matches(limit: int = Query(500, ge=1, le=2000)): """List ESET-matched hardware with contact/customer info.""" query = """ SELECT h.id, h.asset_type, h.brand, h.model, h.serial_number, h.eset_uuid, h.eset_group, h.updated_at, hc.contact_id, c.first_name, c.last_name, c.user_company, cc.customer_id, cust.name AS customer_name FROM hardware_assets h LEFT JOIN hardware_contacts hc ON hc.hardware_id = h.id LEFT JOIN contacts c ON c.id = hc.contact_id LEFT JOIN contact_companies cc ON cc.contact_id = c.id LEFT JOIN customers cust ON cust.id = cc.customer_id WHERE h.deleted_at IS NULL ORDER BY h.updated_at DESC NULLS LAST LIMIT %s """ result = execute_query(query, (limit,)) return result or [] @router.get("/hardware/eset/incidents", response_model=List[dict]) async def list_eset_incidents( severity: Optional[str] = Query("critical"), limit: int = Query(200, ge=1, le=2000) ): """List cached ESET incidents by severity.""" severity_list = [s.strip().lower() for s in (severity or "").split(",") if s.strip()] if not severity_list: severity_list = ["critical"] query = """ SELECT * FROM eset_incidents WHERE LOWER(COALESCE(severity, '')) = ANY(%s) ORDER BY updated_at DESC NULLS LAST LIMIT %s """ result = execute_query(query, (severity_list, limit)) return result or []