{"openapi":"3.0.3","info":{"title":"Nevent Data API","description":"API unificada para segmentación de usuarios y analíticas on our data warehouse.\n\n## Segmentation API\nAPI completa para segmentación de fans basada en criterios dinámicos:\n- **49 criterios de segmentación** organizados en 6 categorías\n- **Preferencias musicales** (NEW v3.18.1): Segmentación por artistas y géneros con selección dropdown\n- **Modificadores avanzados**: Frequency (al menos X veces) + Time Range (últimos X días/meses)\n- **RFM Analysis**: Combina Recency + Frequency + Monetary para análisis predictivo\n- **Campos personalizados** con soporte completo para tipos dinámicos\n- **Lógica DSL flexible**: OR dentro de stanzas, AND entre stanzas\n- **Preview y ejecución** con paginación optimizada\n- **Multi-tenant** con aislamiento automático por JWT\n\n## Analytics API\nAPI flexible para consultas analíticas dinámicas:\n- **Consultas dinámicas**: Frontend define dimensiones, métricas y filtros\n- **Agregaciones temporales**: Granularidad desde minuto hasta año fiscal\n- **CTEs (Common Table Expressions)**: Nested aggregations sin hardcoded SQL\n- **Schema Discovery**: Introspección segura de database tables (Admin only)\n- **AI Capabilities**: Endpoints públicos para asistentes IA\n- **22 operadores de filtrado**: Desde comparación básica hasta regex\n- **13 operaciones de agregación**: Incluye percentiles, condicionales, date arithmetic\n- **SQL dinámico optimizado**: Traducción a SQL eficiente\n\n## Características Comunes\n- **Autenticación JWT**: Todos los endpoints requieren Bearer token\n- **Filtrado automático por tenant**: Seguridad a nivel de datos\n- **database**: Procesamiento sobre datalake unificado\n- **Validación exhaustiva**: Requests validados con TypeScript\n\n---\n\n## 🚀 Quick Start\n\n### 1. Get Your Token\n```bash\n# Development token (for testing)\nnode scripts/generate_prod_token.js\n```\n\n### 2. Your First Query\n```bash\ncurl -X POST https://nev-data-api-dev-76juoiez2a-ew.a.run.app/analytics/query \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"collection\":\"purchases\",\"metrics\":[{\"field\":\"totalPrice\",\"operation\":\"sum\",\"alias\":\"revenue\"}]}'\n```\n\n### 3. Next Steps\n- 📚 [Browse 19 Postman Collections](#developer-resources)\n- 📖 [Read Full Documentation](https://github.com/nevent-dev/nev-data-api/tree/main/docs)\n- 🎯 [Try Interactive Examples](#api-examples)\n\n---\n\n## 🛠️ Developer Resources\n\n### Postman Collections (19 available)\nReady-to-use collections with 100% test coverage:\n\n**Segmentation API**\n- [Complete Segmentation API v3](./postman/Nevent-Segmentation-API-Complete-v3.postman_collection.json) - 45+ criteria, all operators\n- [Custom Fields Tests](./postman/Custom-Fields-Tests.postman_collection.json) - Dynamic custom fields\n\n**Analytics API**\n- [Analytics Phase 4 - Temporal Analysis](./postman/Analytics-API-Phase4-Temporal-Analysis.postman_collection.json) - Hour, minute, fiscal periods\n- [Campaign Analytics Complete](./postman/Campaign-Analytics-Complete-v1.0.postman_collection.json) - Campaign performance\n- [Magic Links Analytics](./postman/Magic-Links-Analytics-v1.0.postman_collection.json) - Click tracking (38 fields)\n- [RFM, Cohorts & Churn](./postman/RFM-Cohorts-Churn-Analytics-v1.0.postman_collection.json) - User analytics\n- [Event Dashboard](./postman/Event-Dashboard-Complete-v1.2-Fixed.postman_collection.json) - Complete event metrics\n\n**[View All 19 Collections →](./postman/)**\n\n### Integration Guides\n- [Angular Integration](./docs/angular-email-usage-client-guide.md) - Complete Angular client guide\n- [Dashboard Metrics](./docs/dashboard-metrics-analysis.md) - Building analytics dashboards\n- [Analytics Architecture](./docs/ANALYTICS_ARCHITECTURE_GUIDE.md) - System design patterns\n- [Testing Guide](./docs/TESTING_GUIDE.md) - Validation best practices\n\n### Code Examples\nAll endpoints include ready-to-use examples in:\n- ✅ JavaScript (Fetch API)\n- ✅ Python (requests)\n- ✅ cURL\n- ✅ Java (HttpClient)\n\n**Total: 76 code examples** across 19 endpoints\n\n---\n\n## ⚡ API Limits & Quotas\n\n### Request Limits\n- **Request timeout**: 60 seconds (Analytics API), 30 seconds (Segmentation API)\n- **Max payload size**: 10 MB\n- **Max response size**: 50 MB (use pagination for larger results)\n\n### Rate Limits\n- **Default**: 1,000 requests/hour per tenant\n- **Analytics Query**: 500 queries/hour per tenant\n- **Schema Discovery**: 10 requests/hour per tenant (Admin only)\n\n### Query Complexity Limits\n- **Max filters**: 50 per query\n- **Max metrics**: 20 per query\n- **Max dimensions**: 10 per query\n- **Max CTEs**: 3 per query (Common Table Expressions)\n\n### Data Warehouse Quotas\n- **Daily queries**: 10,000 per tenant\n- **Concurrent queries**: 10 per tenant\n- **Query size**: 1 MB SQL text limit\n\n### Pagination\n- **Default page size**: 20 results\n- **Max page size**: 1,000 results\n- **Recommended**: 100 results/page for optimal performance\n\n---\n\n## 🆘 Support & Service Level Agreement\n\n### Availability\n- **Uptime SLA**: 99.9% (excluding planned maintenance)\n- **Monthly downtime allowance**: 43 minutes\n- **Status Page**: [status.nevent.com](https://status.nevent.com) _(coming soon)_\n- **Planned Maintenance**: Monthly, Sundays 02:00-04:00 UTC\n\n### Support Channels\n- **Email**: [support@nevent.es](mailto:support@nevent.es)\n- **Emergency SMS**: +34 671 109 746 (Martin), +34 618 792 465 (Samuel)\n- **Support Hours**: Monday-Friday, 9:00-18:00 CET\n- **Documentation**: [Full Docs](https://github.com/nevent-dev/nev-data-api/tree/main/docs)\n\n### Response Times (SLA)\n| Priority | Description | Response Time |\n|----------|-------------|---------------|\n| **P0 - Critical** | API down, complete outage | < 1 hour |\n| **P1 - High** | Major feature broken | < 4 hours |\n| **P2 - Medium** | Minor issue, workaround exists | < 24 hours |\n| **P3 - Low** | Feature request, documentation | < 72 hours |\n\n### Escalation Path\n1. Email: [support@nevent.es](mailto:support@nevent.es)\n2. If no response within SLA: [martin.blanco@nevent.es](mailto:martin.blanco@nevent.es)\n3. Critical emergencies only: SMS to emergency contacts\n\n### Performance Guarantees\n- **Query Response Time**: P95 < 2 seconds\n- **Error Rate**: < 0.1% (5xx responses)\n- **Health Check**: Monitored every 60 seconds\n\n---\n\n## 🔐 Authentication Guide\n\n### JWT Token Structure\nAll requests require a JWT Bearer token with the following claims:\n```json\n{\n  \"sub\": \"user_id\",\n  \"tenantId\": \"683838f4e0532b0378dcf7791\",\n  \"role\": \"ADMIN\",\n  \"email\": \"user@example.com\",\n  \"iat\": 1758991399,\n  \"exp\": 1759596199\n}\n```\n\n### Obtaining Tokens\n1. **Contact your Nevent administrator** for production credentials\n2. **Development**: Generate test token with `node scripts/generate_prod_token.js`\n3. **Token expiration**: 7 days (604,800 seconds)\n\n### Using Tokens\nInclude the token in the Authorization header of every request:\n```bash\nAuthorization: Bearer YOUR_JWT_TOKEN\n```\n\n### RBAC Roles\n- **SUPERADMIN**: Full access to all endpoints and all tenants\n- **ADMIN**: Full access to tenant data, schema discovery, custom fields\n- **USER**: Read-only access to analytics and segmentation (no schema discovery)\n\n### Service sessions (technical admin access)\nnev-api can issue short-lived access tokens to a technical service account\n(`principalType: service`, `serviceSessionId`, `serviceAccountId`, `serviceScope`, `aud`).\ndata-api accepts them only while nev-api confirms the session on every request\n(`GET /auth/me` of the same environment, no cache), so ending the session or disabling the\naccount revokes the token here at once:\n- `401 SERVICE_SESSION_REJECTED`: session ended or inconsistent with the token, wrong `aud`, or\n  malformed service claims.\n- `503 SERVICE_AUTH_UNAVAILABLE`: nev-api could not confirm the session; retry. Never served unverified.\n- `serviceScope: TENANT` reads its tenant and descendants with SUPERADMIN capability; naming a\n  tenant outside that scope (`?tenantId` on `/audit/*`, `x-tenant-id` on campaign report and user\n  interactions) is `403 SERVICE_TENANT_SCOPE`. `serviceScope: PLATFORM` behaves as SUPERADMIN.\n\n### Security Best Practices\n- Store tokens securely (environment variables, secret managers)\n- Never commit tokens to version control\n- Rotate tokens before expiration (7 days)\n- Use HTTPS for all requests (enforced in production)\n\n---\n\n## 📝 Release Notes\n\n### Latest Version: v3.17.0 (2026-01-14)\n**New Features:**\n- ✅ Request ID tracing for distributed systems\n- ✅ Structured logging with Winston\n- ✅ Enhanced error context with Sentry integration\n- ✅ Improved documentation with Redoc extensions\n\n### Recent Updates\n- **v3.12.0** (2026-01-13): User location and Nevent score criteria\n- **v3.10.4** (2025-12-27): AUTO-JOIN for demographics queries\n- **v3.9.0** (2025-06-15): Enhanced click tracking (19 new fields)\n- **v2.8.0** (2025-05-20): Schema Discovery API (Admin only)\n- **v2.7.0** (2025-05-10): Temporal analysis (hour, minute, fiscal periods)\n\n### Breaking Changes\n- **v3.9.0**: `user_tenants.surname1` and `surname2` consolidated into `surname`\n  - Migration: Update queries to use single `surname` field\n  - ETL pipelines must populate new consolidated field\n\n### Full Changelog\n📖 [View Complete CHANGELOG.md](https://github.com/nevent-dev/nev-data-api/blob/main/CHANGELOG.md)\n\n### Version Support Policy\n- **Current version (v3.18.1)**: Full support, new features\n- **Previous minor (v3.17.x)**: Security fixes only (6 months)\n- **Older versions**: End of life, upgrade recommended\n","version":"3.21.0","contact":{"name":"Nevent Support Team","email":"support@nevent.es"},"license":{"name":"Proprietary","url":"https://nevent.ai/license"},"x-logo":{"url":"/assets/logo_negro.png","altText":"Nevent Data Platform","href":"https://nevent.ai","backgroundColor":"#FFFFFF","width":"140px"},"x-badges":[{"name":"OpenAPI 3.0.3","color":"green"},{"name":"JWT Required","color":"blue"},{"name":"Multi-tenant","color":"purple"},{"name":"Production Ready","color":"green"}]},"servers":[{"url":"http://localhost:8081","description":"Servidor de desarrollo local"},{"url":"https://api-dev.nevent.com","description":"Servidor de desarrollo cloud"},{"url":"https://api.nevent.com","description":"Servidor de producción"}],"security":[{"bearerAuth":[]}],"x-tagGroups":[{"name":"🚀 Getting Started","tags":["Health Check","Health"]},{"name":"🎯 Segmentation API","tags":["Segmentation","Custom Fields"]},{"name":"📊 Analytics API","tags":["Query","Filters","Schema Discovery","Analytics Capabilities"]}],"paths":{"/health":{"get":{"tags":["Health"],"summary":"Health check general de la API","description":"Health check endpoint público sin autenticación.\n\nRetorna estado general de servicios:\n- API status\n- database connectivity\n- Swagger documentation availability\n- Version de la API\n\n**Útil para**:\n- Monitoreo de uptime\n- Load balancer health checks\n- CI/CD deployment validation\n","operationId":"getHealth","security":[],"responses":{"200":{"description":"API saludable","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["healthy"],"example":"healthy"},"version":{"type":"string","description":"Versión del package.json","example":"3.9.0"},"commit":{"type":"string","nullable":true,"description":"Commit de la imagen desplegada (build arg GIT_COMMIT de CI); null en local","example":"0f3c2a9d8e7b6a5c4d3e2f1a0b9c8d7e6f5a4b3c"},"revision":{"type":"string","nullable":true,"description":"Revisión de Cloud Run que responde (K_REVISION); null en local","example":"nev-data-api-prod-00042-abc"},"timestamp":{"type":"string","format":"date-time","description":"Timestamp ISO 8601"},"services":{"type":"object","properties":{"database":{"type":"string","enum":["up","down"]},"api":{"type":"string","enum":["up","down"]},"swagger":{"type":"string","enum":["up","down"]}}},"documentation":{"type":"object","nullable":true,"properties":{"swagger_ui":{"type":"string","example":"/docs"},"openapi_spec":{"type":"string","example":"/swagger.json"}}}}},"examples":{"healthy":{"summary":"API saludable","value":{"status":"healthy","version":"3.9.0","timestamp":"2025-12-27T10:30:00.000Z","services":{"database":"up","api":"up","swagger":"up"},"documentation":{"swagger_ui":"/docs","openapi_spec":"/swagger.json"}}}}}}}},"x-code-samples":[{"lang":"JavaScript","label":"JavaScript (Fetch)","source":"// Health check general de la API\nconst response = await fetch('https://api.nevent.es/health', {\n  method: 'GET',\n  headers: {\n    'Authorization': 'Bearer YOUR_TOKEN',\n    'Content-Type': 'application/json'\n  }\n});\n\nconst data = await response.json();\nconsole.log(data);"},{"lang":"Python","label":"Python (requests)","source":"# Health check general de la API\nimport requests\n\nresponse = requests.get(\n    'https://api.nevent.es/health',\n    headers={'Authorization': 'Bearer YOUR_TOKEN'}\n)\n\ndata = response.json()\nprint(data)"},{"lang":"Shell","label":"cURL","source":"# Health check general de la API\ncurl -X GET https://api.nevent.es/health \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\""},{"lang":"Java","label":"Java (HttpClient)","source":"// Health check general de la API\nHttpClient client = HttpClient.newHttpClient();\n\nHttpRequest request = HttpRequest.newBuilder()\n    .uri(URI.create(\"https://api.nevent.es/health\"))\n    .header(\"Authorization\", \"Bearer YOUR_TOKEN\")\n    .header(\"Content-Type\", \"application/json\")\n    .GET(HttpRequest.BodyPublishers.noBody())\n    .build();\n\nHttpResponse<String> response = client.send(request,\n    HttpResponse.BodyHandlers.ofString());\nSystem.out.println(response.body());"}]}},"/segmentation/criteria":{"get":{"tags":["Segmentation"],"summary":"Obtener criterios de segmentación disponibles","description":"Retorna todos los criterios de segmentación activos organizados por categorías.\n\nIncluye **45+ criterios** distribuidos en 6 categorías:\n- **Asistencia** (14 criterios): Eventos, ubicaciones, compras de entradas\n- **Atributos del fan** (8 criterios): Datos demográficos y personales\n- **Consumo** (7 criterios): Comportamiento de compra y gasto\n- **Interacción** (8 criterios): Engagement con campañas\n- **Canales** (5 criterios): Canales de comunicación conectados\n- **Nevent Score®** (1 criterio): Temperatura del usuario\n\nCada criterio incluye:\n- ID único para referencia en el DSL\n- Operadores disponibles según el tipo de dato\n- Metadata de categoría para organización en UI\n- Modificadores soportados (distance, frequency, time_range)\n","operationId":"getCriteria","responses":{"200":{"description":"Lista de criterios obtenida exitosamente","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CriteriaResponse"},"examples":{"success":{"summary":"Respuesta exitosa con criterios","value":{"success":true,"criteria":[{"id":"user_age","name":"Edad del usuario","description":"Edad actual del usuario","type":"profile_property","data_type":"NUMBER","category":"atributos_fan","category_metadata":{"id":"atributos_fan","name":"Atributos del fan","description":"Información demográfica y personal","icon":"user","order":2},"operators":[{"id":"eq","name":"Igual a","description":"El valor es exactamente igual","requiresValue":true,"valueType":"single"},{"id":"gt","name":"Mayor que","description":"El valor es mayor que","requiresValue":true,"valueType":"single"}],"supported_modifiers":{"time_range":true}},{"id":"attended_event","name":"Evento asistido","description":"Ha asistido a un evento específico","type":"behavior","data_type":"ENTITY","category":"asistencia","operators":[{"id":"is","name":"Es","description":"El valor es igual a","requiresValue":true,"valueType":"single"},{"id":"is_not","name":"No es","description":"El valor no es igual a","requiresValue":true,"valueType":"single"}],"supported_modifiers":{"distance":false,"frequency":false,"time_range":true}}]}}}}}},"500":{"$ref":"#/components/responses/InternalServerError"}},"x-code-samples":[{"lang":"JavaScript","label":"JavaScript (Fetch)","source":"// Obtener criterios de segmentación disponibles\nconst response = await fetch('https://api.nevent.es/segmentation/criteria', {\n  method: 'GET',\n  headers: {\n    'Authorization': 'Bearer YOUR_TOKEN',\n    'Content-Type': 'application/json'\n  }\n});\n\nconst data = await response.json();\nconsole.log(data);"},{"lang":"Python","label":"Python (requests)","source":"# Obtener criterios de segmentación disponibles\nimport requests\n\nresponse = requests.get(\n    'https://api.nevent.es/segmentation/criteria',\n    headers={'Authorization': 'Bearer YOUR_TOKEN'}\n)\n\ndata = response.json()\nprint(data)"},{"lang":"Shell","label":"cURL","source":"# Obtener criterios de segmentación disponibles\ncurl -X GET https://api.nevent.es/segmentation/criteria \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\""},{"lang":"Java","label":"Java (HttpClient)","source":"// Obtener criterios de segmentación disponibles\nHttpClient client = HttpClient.newHttpClient();\n\nHttpRequest request = HttpRequest.newBuilder()\n    .uri(URI.create(\"https://api.nevent.es/segmentation/criteria\"))\n    .header(\"Authorization\", \"Bearer YOUR_TOKEN\")\n    .header(\"Content-Type\", \"application/json\")\n    .GET(HttpRequest.BodyPublishers.noBody())\n    .build();\n\nHttpResponse<String> response = client.send(request,\n    HttpResponse.BodyHandlers.ofString());\nSystem.out.println(response.body());"}]}},"/health/economic-data":{"get":{"tags":["Health"],"summary":"Frescura de los datos económicos de segmentación","description":"Estado de `purchase_economic_facts`, que construye nevent-data cada 15 min junto al dim de compras\n(#57). Pensado para un uptime check: 200 si CURRENT, 503 si STALE (se han perdido dos o más\nciclos más un margen de 5 min) o UNKNOWN (la tabla de frescura no se pudo leer). La API sigue\nsirviendo en los dos casos, y preview/execute/audience-preview declaran el mismo estado.\nMide vitalidad (que facts se reconstruye a tiempo), no completitud: un 200 no garantiza que\nestén reflejados todos los cambios de origen.\nLa metadata de la tabla se cachea 60 s y la fila se relee solo cuando la tabla cambia.\n","operationId":"getEconomicDataHealth","security":[],"responses":{"200":{"description":"Datos económicos al día","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EconomicDataHealth"},"example":{"economic_data_as_of":"2026-09-23T09:58:12.000Z","economic_data_built_at":"2026-09-23T10:00:08.000Z","economic_data_status":"CURRENT","cadence_minutes":15,"max_age_minutes":35,"age_minutes":7.4}}}},"503":{"description":"STALE o UNKNOWN","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EconomicDataHealth"},"example":{"economic_data_as_of":"2026-09-23T09:58:12.000Z","economic_data_built_at":"2026-09-23T10:00:08.000Z","economic_data_status":"STALE","cadence_minutes":15,"max_age_minutes":35,"age_minutes":52.1}}}}}}},"/dimensions/values":{"post":{"tags":["Segmentation"],"summary":"Obtener valores posibles para un criterio","description":"Retorna los valores disponibles para un criterio específico, filtrados por tenant.\n\n**Criterios económicos** (`economic_metric`: total_spent, average_spent, cashless_*): no tienen\nvalores enumerables; responde 400 con `CRITERION_HAS_NO_VALUES` (#57).\n\n**Para criterios TEXT**: Retorna array de strings únicos\n```json\n[\"España\", \"Francia\", \"Portugal\"]\n```\n\n**Para criterios ENTITY**: Retorna array de objetos con id, name y description (opcional)\n```json\n[\n  {\"id\": \"evt_123\", \"name\": \"Primavera Sound 2024\", \"description\": \"15 Jul 2024\"},\n  {\"id\": \"evt_456\", \"name\": \"Mad Cool Festival 2024\", \"description\": \"20 Aug 2024\"}\n]\n```\n\nEl campo `description` es opcional y solo aparece en ciertos criterios:\n- `attended_event`: Fecha del evento\n- `ticket_type`: Nombre del evento al que pertenece\n\n**Para custom fields**: Incluir `context.property_name` en el body\n```json\n{\n  \"criterion_id\": \"user_custom_field\",\n  \"context\": {\n    \"property_name\": \"preferred_music_genre\"\n  }\n}\n```\n\nSoporta búsqueda avanzada con `search_term` (v3.20.0): multi-término, normalización de acentos,\nbúsqueda en description y ranking por relevancia. Completamente retrocompatible.\n","operationId":"getDimensionValues","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DimensionValuesRequest"},"examples":{"text_criterion":{"summary":"Criterio TEXT (códigos postales)","value":{"criterion_id":"user_zip_code","search_term":"280","limit":20}},"entity_criterion":{"summary":"Criterio ENTITY (eventos)","value":{"criterion_id":"attended_event","search_term":"Primavera","limit":10}},"custom_field":{"summary":"Custom field (género musical)","value":{"criterion_id":"user_custom_field","search_term":"Rock","limit":15,"context":{"property_name":"preferred_music_genre"}}}}}}},"responses":{"200":{"description":"Valores obtenidos exitosamente","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DimensionValuesResponse"},"examples":{"text_values":{"summary":"Valores TEXT","value":{"success":true,"values":["28001","28002","28003","28004"]}},"entity_values":{"summary":"Valores ENTITY (con description)","value":{"success":true,"values":[{"id":"evt_ps2024","name":"Primavera Sound 2024","description":"15 Jul 2024"},{"id":"evt_ps2023","name":"Primavera Sound 2023","description":"20 Jul 2023"}]}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"403":{"$ref":"#/components/responses/DataAccessForbidden"},"500":{"$ref":"#/components/responses/InternalServerError"},"503":{"$ref":"#/components/responses/TenantHierarchyUnavailable"}},"x-code-samples":[{"lang":"JavaScript","label":"JavaScript (Fetch)","source":"// Obtener valores posibles para un criterio\nconst response = await fetch('https://api.nevent.es/dimensions/values', {\n  method: 'POST',\n  headers: {\n    'Authorization': 'Bearer YOUR_TOKEN',\n    'Content-Type': 'application/json'\n  },\n  body: JSON.stringify({\n    \"criterion_id\": \"attended_event\",\n    \"search_term\": \"festival\",\n    \"limit\": 20\n  })\n});\n\nconst data = await response.json();\nconsole.log(data);"},{"lang":"Python","label":"Python (requests)","source":"# Obtener valores posibles para un criterio\nimport requests\n\nresponse = requests.post(\n    'https://api.nevent.es/dimensions/values',\n    headers={'Authorization': 'Bearer YOUR_TOKEN'},\n    json={\n        \"criterion_id\": \"attended_event\",\n        \"search_term\": \"festival\",\n        \"limit\": 20\n    }\n)\n\ndata = response.json()\nprint(data)"},{"lang":"Shell","label":"cURL","source":"# Obtener valores posibles para un criterio\ncurl -X POST https://api.nevent.es/dimensions/values \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"criterion_id\":\"attended_event\",\"search_term\":\"festival\",\"limit\":20}'"},{"lang":"Java","label":"Java (HttpClient)","source":"// Obtener valores posibles para un criterio\nHttpClient client = HttpClient.newHttpClient();\n\nHttpRequest request = HttpRequest.newBuilder()\n    .uri(URI.create(\"https://api.nevent.es/dimensions/values\"))\n    .header(\"Authorization\", \"Bearer YOUR_TOKEN\")\n    .header(\"Content-Type\", \"application/json\")\n    .POST(HttpRequest.BodyPublishers.ofString(jsonBody))\n    .build();\n\nString jsonBody = \"\"\"\n{\n  \"criterion_id\": \"attended_event\",\n  \"search_term\": \"festival\",\n  \"limit\": 20\n}\n\"\"\";\n\nrequest.POST(HttpRequest.BodyPublishers.ofString(jsonBody));\n\nHttpResponse<String> response = client.send(request,\n    HttpResponse.BodyHandlers.ofString());\nSystem.out.println(response.body());"}]}},"/segments/preview":{"post":{"tags":["Segmentation"],"summary":"Vista previa rápida de segmento","description":"Ejecuta una vista previa rápida del segmento retornando:\n- **Estimación del total** de fans que cumplen los criterios\n- **Muestra de hasta 15 fans** para validación\n\n**Lógica de Stanzas**:\n- Criterios dentro de un stanza se unen con **OR**\n- Stanzas entre sí se unen con **AND**\n\n**Ejemplo**: \"Usuarios de España O Francia Y que asistieron a Rock Festival\"\n```json\n{\n  \"stanzas\": [\n    {\n      \"id\": \"location\",\n      \"criteria\": [\n        {\"criterion_id\": \"event_country\", \"operator\": \"eq\", \"value\": \"España\"},\n        {\"criterion_id\": \"event_country\", \"operator\": \"eq\", \"value\": \"Francia\"}\n      ]\n    },\n    {\n      \"id\": \"event\",\n      \"criteria\": [\n        {\"criterion_id\": \"attended_event\", \"operator\": \"is\", \"value\": \"evt_rock\"}\n      ]\n    }\n  ]\n}\n```\n\n**Timeframes**: el campo `timeframe` se acepta por compatibilidad pero no\nfiltra; la ventana temporal es el modificador `time_range` (abajo).\n\n**Modifiers**: Algunos criterios soportan modificadores:\n- `distance`: Radio geográfico en km\n- `frequency`: Número de veces (at_least, exactly, at_most)\n- `time_range`: Rango temporal (last 30 days, etc.), solo en criterios con\n  `supported_modifiers.time_range: true`. En `attended_event` es la fecha de\n  inicio del mismo evento asistido, en días naturales de Europe/Madrid, sin\n  eventos futuros (ver el ejemplo `attended_last_90_days`).\n\n**Contactabilidad (NUEVO)**: la respuesta incluye además:\n- `contactable_counts`: fans contactables por email/SMS/push dentro del\n  segmento, o `null` si el mart `user_tenants` aún no expone esas columnas\n  (rollout gradual — ver `schemaCapabilityService`).\n- `email_exclusions`: desglose de por qué el resto no es contactable por\n  email (`no_email`, `invalid_email`, `opt_out`, `unknown`), o `null` junto\n  con `contactable_counts`. Invariante: `estimated_fan_count =\n  contactable_counts.email + Σ email_exclusions`.\n- `computed_at` / `data_as_of`: timestamp del cálculo y de la frescura del\n  mart, respectivamente.\n- `sample_fans[].email_marketing_status`: solo presente en el path\n  enriquecido (mismo gate que `contactable_counts`).\n\nEl consentimiento de marketing se evalúa **solo contra el tenant con el\nque autenticó la request** (nunca contra la jerarquía completa de tenants\nusada para la pertenencia al segmento).\n","operationId":"previewSegment","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SegmentPreviewRequest"},"examples":{"simple_age":{"summary":"Filtro simple por edad","value":{"definition":{"stanzas":[{"id":"age_filter","criteria":[{"id":"min_age","criterion_id":"user_age","operator":"gte","value":25}]}]}}},"location_and_event":{"summary":"Ubicación Y evento","value":{"definition":{"stanzas":[{"id":"location","criteria":[{"id":"country_spain","criterion_id":"event_country","operator":"eq","value":"España"}]},{"id":"event_attendance","criteria":[{"id":"attended_ps","criterion_id":"attended_event","operator":"is","value":"evt_ps2024"}]}]}}},"with_custom_field":{"summary":"Con campo personalizado","value":{"definition":{"stanzas":[{"id":"music_preference","criteria":[{"id":"genre_filter","criterion_id":"user_custom_field","operator":"contains","value":"Rock","filters":{"property_name":"preferred_music_genre"}}]}]}}},"with_edition":{"summary":"Gasto en una edición (#58)","description":"Solo cuentan las compras y consumos de los miembros CONFIRMED de la edición\n(filters.edition_id, de una organización accesible). Opcional\nfilters.edition_revision fija la revisión del vínculo. Errores visibles:\n404 EDITION_NOT_FOUND, 400 EDITION_WITHOUT_CONFIRMED_MEMBERS,\n400 EDITION_MEMBER_OUT_OF_SCOPE, 503 EDITION_SCOPE_UNAVAILABLE.\n","value":{"definition":{"stanzas":[{"id":"edition_spend","criteria":[{"id":"spent_2026","criterion_id":"edition_spent","operator":"gt","value":100,"filters":{"edition_id":"6ab4f2ea8f0f4cd41813aae8"}}]}]}}},"edition_ticket_buyers":{"summary":"Compradores de más de 3 entradas en una edición (#61)","description":"Suma entradas de acceso vigentes por comprador y edición, no pedidos ni titulares.\nSolo eventos CONFIRMED con rol ADMISSION; excluye alojamientos, complementos,\ninvitaciones, listas de espera y entradas canceladas/devueltas. Detalles ausentes,\ntransferencias o estados sin resolver dejan el total desconocido y no cumplen\nninguna comparación. Datos sincronizados, no tiempo real. Umbrales enteros\nno negativos: gt, gte, eq, neq, lt, lte, between. Una edición sin eventos de\nacceso devuelve 400 EDITION_WITHOUT_ADMISSION_EVENTS; roles sin clasificar,\n400 EDITION_ADMISSION_ROLE_REQUIRED. La revisión fija los vínculos, no los datos.\n","value":{"definition":{"stanzas":[{"id":"group_buyers","criteria":[{"id":"admission_count","criterion_id":"edition_tickets_purchased","operator":"gt","value":3,"filters":{"edition_id":"6ab4f2ea8f0f4cd41813aae8"}}]}]}}},"edition_cashless":{"summary":"Recarga pagada y consumo cashless en la misma edición (#60)","description":"Importes en EUR. Cada stanza es AND; los criterios dentro de una stanza son OR.\nedition_cashless_recharge_paid mide lo pagado; edition_cashless_recharge_credited\nmide saldo cargado con bonificaciones; edition_cashless_consumption mide consumo\ncashless cobrado, sin entradas, complementos, recargas ni líneas de producto.\nNo prueba asistencia escaneada. Importes desconocidos no cumplen comparaciones.\nLa revisión fija vínculos, no datos. No combinar un umbral histórico con una edición.\n","value":{"definition":{"stanzas":[{"id":"topup","criteria":[{"id":"paid","criterion_id":"edition_cashless_recharge_paid","operator":"gt","value":50,"filters":{"edition_id":"6ab6a00b76b3df585bf1c7e1","edition_revision":7}}]},{"id":"consumption","criteria":[{"id":"consumed","criterion_id":"edition_cashless_consumption","operator":"gt","value":30,"filters":{"edition_id":"6ab6a00b76b3df585bf1c7e1","edition_revision":7}}]}]}}},"attended_last_90_days":{"summary":"Asistentes en los últimos 90 días (#125)","description":"`modifiers.time_range` sobre `attended_event`: el MISMO evento asistido empezó\nentre las 00:00 (Europe/Madrid) de hoy − 90 días y ahora, ambos incluidos. Los\neventos futuros no cuentan y la ventana se recalcula en cada evaluación.\nCon `is_not` / `is_not_set` la ventana se aplica a la negación («no asistió en\nlos últimos 90 días»).\n","value":{"definition":{"stanzas":[{"id":"recent_attendance","criteria":[{"id":"recent_event","criterion_id":"attended_event","operator":"is_set","modifiers":{"time_range":{"value":90,"unit":"days"}}}]}]}}},"with_modifiers":{"summary":"Con modificadores","value":{"definition":{"stanzas":[{"id":"frequent_attendees","criteria":[{"id":"multiple_events","criterion_id":"attended_event_with_label","operator":"is","value":"lbl_festival","modifiers":{"frequency":{"count":3,"operator":"at_least"},"time_range":{"value":12,"unit":"months","direction":"past"}}}]}]}}}}}}},"responses":{"200":{"description":"Preview generado exitosamente","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PreviewResponse"},"examples":{"success":{"summary":"Preview exitoso (mart de contactabilidad no disponible)","value":{"success":true,"estimated_fan_count":1247,"sample_fans":[{"fan_id":"usr_001","first_name":"Juan","last_name":"García","email":"juan.garcia@example.com","phone":"+34600123456","dni":"12345678A"},{"fan_id":"usr_002","first_name":"María","last_name":"López","email":"maria.lopez@example.com","phone":"+34600789012","dni":"87654321B"}],"contactable_counts":null,"email_exclusions":null,"computed_at":"2026-07-17T09:15:32.000Z","data_as_of":null}},"success_with_contactability":{"summary":"Preview exitoso (con contactabilidad)","value":{"success":true,"estimated_fan_count":1000,"sample_fans":[{"fan_id":"6547c6a70f6fa76418cb3c1a","first_name":"Ana","last_name":"Gomez","email":"ana.gomez@example.com","phone":"+34600111222","dni":"12345678A","email_marketing_status":"ok"},{"fan_id":"6547c6a70f6fa76418cb3c1b","first_name":"Bruno","last_name":"Ruiz","email":"bruno.ruiz@example.com","phone":"+34600333444","dni":"87654321B","email_marketing_status":"opt_out"}],"contactable_counts":{"email":612,"sms":430,"push":210},"email_exclusions":{"no_email":120,"invalid_email":45,"opt_out":88,"unknown":135},"computed_at":"2026-07-17T09:15:32.000Z","data_as_of":"2026-07-16T03:00:00.000Z"}}}}}},"400":{"$ref":"#/components/responses/SegmentValidationError"},"403":{"$ref":"#/components/responses/DataAccessForbidden"},"500":{"$ref":"#/components/responses/InternalServerError"},"503":{"$ref":"#/components/responses/TenantHierarchyUnavailable"}},"x-code-samples":[{"lang":"JavaScript","label":"JavaScript (Fetch)","source":"// Vista previa rápida de segmento\nconst response = await fetch('https://api.nevent.es/segments/preview', {\n  method: 'POST',\n  headers: {\n    'Authorization': 'Bearer YOUR_TOKEN',\n    'Content-Type': 'application/json'\n  },\n  body: JSON.stringify({\n    \"definition\": {\n      \"stanzas\": [\n        {\n          \"id\": \"age_filter\",\n          \"criteria\": [\n            {\n              \"id\": \"age_criterion\",\n              \"criterion_id\": \"user_age\",\n              \"operator\": \"gte\",\n              \"value\": 25\n            }\n          ]\n        }\n      ]\n    }\n  })\n});\n\nconst data = await response.json();\nconsole.log(data);"},{"lang":"Python","label":"Python (requests)","source":"# Vista previa rápida de segmento\nimport requests\n\nresponse = requests.post(\n    'https://api.nevent.es/segments/preview',\n    headers={'Authorization': 'Bearer YOUR_TOKEN'},\n    json={\n        \"definition\": {\n            \"stanzas\": [\n                {\n                    \"id\": \"age_filter\",\n                    \"criteria\": [\n                        {\n                            \"id\": \"age_criterion\",\n                            \"criterion_id\": \"user_age\",\n                            \"operator\": \"gte\",\n                            \"value\": 25\n                        }\n                    ]\n                }\n            ]\n        }\n    }\n)\n\ndata = response.json()\nprint(data)"},{"lang":"Shell","label":"cURL","source":"# Vista previa rápida de segmento\ncurl -X POST https://api.nevent.es/segments/preview \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"definition\":{\"stanzas\":[{\"id\":\"age_filter\",\"criteria\":[{\"id\":\"age_criterion\",\"criterion_id\":\"user_age\",\"operator\":\"gte\",\"value\":25}]}]}}'"},{"lang":"Java","label":"Java (HttpClient)","source":"// Vista previa rápida de segmento\nHttpClient client = HttpClient.newHttpClient();\n\nHttpRequest request = HttpRequest.newBuilder()\n    .uri(URI.create(\"https://api.nevent.es/segments/preview\"))\n    .header(\"Authorization\", \"Bearer YOUR_TOKEN\")\n    .header(\"Content-Type\", \"application/json\")\n    .POST(HttpRequest.BodyPublishers.ofString(jsonBody))\n    .build();\n\nString jsonBody = \"\"\"\n{\n  \"definition\": {\n    \"stanzas\": [\n      {\n        \"id\": \"age_filter\",\n        \"criteria\": [\n          {\n            \"id\": \"age_criterion\",\n            \"criterion_id\": \"user_age\",\n            \"operator\": \"gte\",\n            \"value\": 25\n          }\n        ]\n      }\n    ]\n  }\n}\n\"\"\";\n\nrequest.POST(HttpRequest.BodyPublishers.ofString(jsonBody));\n\nHttpResponse<String> response = client.send(request,\n    HttpResponse.BodyHandlers.ofString());\nSystem.out.println(response.body());"}]}},"/segments/execute":{"post":{"tags":["Segmentation"],"summary":"Ejecutar segmento con paginación","description":"Ejecuta el segmento completo retornando todos los fans que cumplen los criterios.\n\n**Paginación**:\n- `page_size`: Número de fans por página (default: 100, max: 1000)\n- `page`: Número de página 0-indexed (default: 0)\n\n**Response**:\n- `total_fans`: Total de fans en el segmento\n- `fans`: Array de fans de la página actual\n- `current_page`: Página actual (0-indexed)\n- `total_pages`: Total de páginas disponibles\n- `has_more`: true si hay más páginas\n\n**Performance**:\n- Primera ejecución hace COUNT para total\n- Resultados se obtienen con LIMIT/OFFSET optimizado\n- Recomendado usar page_size entre 50-500 según uso\n\nLa estructura de `definition` es idéntica a `/segments/preview`.\n","operationId":"executeSegment","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SegmentExecuteRequest"},"examples":{"first_page":{"summary":"Primera página","value":{"definition":{"stanzas":[{"id":"age_filter","criteria":[{"id":"min_age","criterion_id":"user_age","operator":"gte","value":18}]}]},"page_size":100,"page":0}},"second_page":{"summary":"Segunda página","value":{"definition":{"stanzas":[{"id":"age_filter","criteria":[{"id":"min_age","criterion_id":"user_age","operator":"gte","value":18}]}]},"page_size":100,"page":1}},"large_page":{"summary":"Página grande para exportación","value":{"definition":{"stanzas":[{"id":"location","criteria":[{"id":"madrid","criterion_id":"event_city","operator":"eq","value":"Madrid"}]}]},"page_size":500,"page":0}}}}}},"responses":{"200":{"description":"Segmento ejecutado exitosamente","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecuteResponse"},"examples":{"first_page_result":{"summary":"Primera página de resultados","value":{"success":true,"total_fans":1247,"current_page":0,"total_pages":13,"has_more":true,"fans":[{"fan_id":"usr_001","first_name":"Juan","last_name":"García","email":"juan.garcia@example.com","phone":"+34600123456","dni":"12345678A"}]}},"last_page_result":{"summary":"Última página de resultados","value":{"success":true,"total_fans":1247,"current_page":12,"total_pages":13,"has_more":false,"fans":[{"fan_id":"usr_1247","first_name":"Ana","last_name":"Martín","email":"ana.martin@example.com","phone":"+34600999888","dni":"99988877C"}]}}}}}},"400":{"$ref":"#/components/responses/SegmentValidationError"},"403":{"$ref":"#/components/responses/DataAccessForbidden"},"500":{"$ref":"#/components/responses/InternalServerError"},"503":{"$ref":"#/components/responses/TenantHierarchyUnavailable"}},"x-code-samples":[{"lang":"JavaScript","label":"JavaScript (Fetch)","source":"// Ejecutar segmento con paginación\nconst response = await fetch('https://api.nevent.es/segments/execute', {\n  method: 'POST',\n  headers: {\n    'Authorization': 'Bearer YOUR_TOKEN',\n    'Content-Type': 'application/json'\n  },\n  body: JSON.stringify({\n    \"definition\": {\n      \"stanzas\": [\n        {\n          \"id\": \"age_filter\",\n          \"criteria\": [\n            {\n              \"id\": \"age_criterion\",\n              \"criterion_id\": \"user_age\",\n              \"operator\": \"gte\",\n              \"value\": 25\n            }\n          ]\n        }\n      ]\n    },\n    \"page_size\": 50,\n    \"page\": 1\n  })\n});\n\nconst data = await response.json();\nconsole.log(data);"},{"lang":"Python","label":"Python (requests)","source":"# Ejecutar segmento con paginación\nimport requests\n\nresponse = requests.post(\n    'https://api.nevent.es/segments/execute',\n    headers={'Authorization': 'Bearer YOUR_TOKEN'},\n    json={\n        \"definition\": {\n            \"stanzas\": [\n                {\n                    \"id\": \"age_filter\",\n                    \"criteria\": [\n                        {\n                            \"id\": \"age_criterion\",\n                            \"criterion_id\": \"user_age\",\n                            \"operator\": \"gte\",\n                            \"value\": 25\n                        }\n                    ]\n                }\n            ]\n        },\n        \"page_size\": 50,\n        \"page\": 1\n    }\n)\n\ndata = response.json()\nprint(data)"},{"lang":"Shell","label":"cURL","source":"# Ejecutar segmento con paginación\ncurl -X POST https://api.nevent.es/segments/execute \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"definition\":{\"stanzas\":[{\"id\":\"age_filter\",\"criteria\":[{\"id\":\"age_criterion\",\"criterion_id\":\"user_age\",\"operator\":\"gte\",\"value\":25}]}]},\"page_size\":50,\"page\":1}'"},{"lang":"Java","label":"Java (HttpClient)","source":"// Ejecutar segmento con paginación\nHttpClient client = HttpClient.newHttpClient();\n\nHttpRequest request = HttpRequest.newBuilder()\n    .uri(URI.create(\"https://api.nevent.es/segments/execute\"))\n    .header(\"Authorization\", \"Bearer YOUR_TOKEN\")\n    .header(\"Content-Type\", \"application/json\")\n    .POST(HttpRequest.BodyPublishers.ofString(jsonBody))\n    .build();\n\nString jsonBody = \"\"\"\n{\n  \"definition\": {\n    \"stanzas\": [\n      {\n        \"id\": \"age_filter\",\n        \"criteria\": [\n          {\n            \"id\": \"age_criterion\",\n            \"criterion_id\": \"user_age\",\n            \"operator\": \"gte\",\n            \"value\": 25\n          }\n        ]\n      }\n    ]\n  },\n  \"page_size\": 50,\n  \"page\": 1\n}\n\"\"\";\n\nrequest.POST(HttpRequest.BodyPublishers.ofString(jsonBody));\n\nHttpResponse<String> response = client.send(request,\n    HttpResponse.BodyHandlers.ofString());\nSystem.out.println(response.body());"}]}},"/segments/execute-ids":{"post":{"tags":["Segmentation"],"summary":"Ids de los fans de una ejecución, por páginas de hasta 25000","description":"Devuelve solo los `fan_id` que devolvería `/segments/execute` para la misma petición: misma\n`definition`, mismos tenants (los del JWT, nunca del cuerpo), mismos `groups` y mismo\n`definition.limit`/`offset`. Sin datos de contacto ni enriquecimiento de consentimiento.\n\n**Paginación por clave** (`after_user_id`): ids distintos y no vacíos en orden ascendente;\nla siguiente página se pide con `after_user_id` = `next_after_user_id` de la anterior.\nCada página es una consulta nueva sobre datos vivos, no una instantánea: un fan que entra o\nsale entre dos páginas no desplaza a los demás, pero puede aparecer o no según el momento.\n`page` no se admite (400 `INVALID_PAGINATION`).\n\n- `page_size`: 1–25000 (default 100).\n- `has_more`: hay otra página; `next_after_user_id` es `null` cuando no la hay.\n\n**Rate limit**: comparte el límite de `/segments/execute` (30 req/min por tenant): las\npáginas de ambos endpoints gastan el mismo presupuesto.\n","operationId":"executeSegmentIds","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["definition"],"properties":{"definition":{"$ref":"#/components/schemas/Definition"},"page_size":{"type":"integer","minimum":1,"maximum":25000,"default":100},"after_user_id":{"type":"string","nullable":true,"maxLength":256,"description":"Último id de la página anterior; ausente o null para la primera."},"groups":{"$ref":"#/components/schemas/GroupsConfig"}}},"examples":{"first_page":{"summary":"Primera página","value":{"definition":{"stanzas":[{"id":"age_filter","criteria":[{"id":"min_age","criterion_id":"user_age","operator":"gte","value":18}]}]},"page_size":25000}},"next_page":{"summary":"Página siguiente","value":{"definition":{"stanzas":[{"id":"age_filter","criteria":[{"id":"min_age","criterion_id":"user_age","operator":"gte","value":18}]}]},"page_size":25000,"after_user_id":"6512a0b1c4c550aa2e9365ff"}}}}}},"responses":{"200":{"description":"Página de ids","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"fan_ids":{"type":"array","items":{"type":"string"}},"page_size":{"type":"integer"},"has_more":{"type":"boolean"},"next_after_user_id":{"type":"string","nullable":true}}},"examples":{"more":{"summary":"Hay más páginas","value":{"success":true,"fan_ids":["6512a0b1c4c550aa2e936501","6512a0b1c4c550aa2e9365ff"],"page_size":2,"has_more":true,"next_after_user_id":"6512a0b1c4c550aa2e9365ff"}},"last":{"summary":"Última página","value":{"success":true,"fan_ids":["6512a0b1c4c550aa2e936700"],"page_size":2,"has_more":false,"next_after_user_id":null}}}}}},"400":{"$ref":"#/components/responses/SegmentValidationError"},"403":{"$ref":"#/components/responses/DataAccessForbidden"},"500":{"$ref":"#/components/responses/InternalServerError"},"503":{"$ref":"#/components/responses/TenantHierarchyUnavailable"}}}},"/segments/audience-preview":{"post":{"tags":["Segmentation"],"summary":"Vista previa de audiencia combinando múltiples definiciones (NUEVO)","description":"Combina hasta 20 definiciones DSL con **UNION DISTINCT** — un fan que\ncumpla CUALQUIERA de las definiciones cuenta una sola vez en\n`unique_total` — y devuelve el alcance de la audiencia combinada más su\ncontactabilidad por canal, para un `consent_mode` dado. Evita tener que\nhacer N llamadas a `/segments/preview` cuando se quiere estimar el\nalcance de varios segmentos combinados (p.ej. antes de lanzar una\ncampaña multi-segmento).\n\n**Rate limit**: reutiliza el límite de `/segments/preview` (50 req/min)\n— es un endpoint de solo lectura con el mismo perfil de coste (sin\npaginación, sin exportación de datos), solo que evaluado sobre N\ndefiniciones en lugar de una.\n\n**`consent_mode`** (`MARKETING` por defecto, o `TRANSACTIONAL`):\n- `MARKETING`: idéntico a `/segments/preview` — elegible por email\n  significa `email_marketing_status = 'ok'`.\n- `TRANSACTIONAL`: `opt_out` y `unknown` siguen siendo alcanzables para\n  email transaccional/de servicio (solo `no_email`/`invalid_email`\n  excluyen); `email_exclusions.opt_out` se omite en este modo.\n\nEl consentimiento se evalúa **solo contra el tenant con el que\nautenticó la request**, nunca contra la jerarquía completa de tenants\n(misma regla que `/segments/preview` y `/segments/execute`).\n\n**Errores de validación (400)**: se reporta la primera violación\nencontrada, con el índice de la definición ofensiva cuando aplica\n(`definitions[i]`):\n- `definitions` vacío, no-array, o con más de 20 elementos\n- `channels` vacío, no-array, con duplicados, o con un valor fuera de\n  `EMAIL`/`SMS`/`PUSH`\n- `consent_mode` distinto de `MARKETING`/`TRANSACTIONAL`\n- Cualquier `definitions[i]` con una estructura DSL inválida\n","operationId":"audiencePreview","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AudiencePreviewRequest"},"examples":{"vip_or_recent_attendee":{"summary":"VIP (gasto alto) O asistió a un evento reciente","value":{"definitions":[{"stanzas":[{"id":"stanza_vip","criteria":[{"id":"crit_vip_spend","type":"behavior","criterion_id":"total_spent","operator":"gte","value":500}]}]},{"stanzas":[{"id":"stanza_recent","criteria":[{"id":"crit_recent_event","type":"behavior","criterion_id":"attended_event","operator":"is","value":"6547c6a70f6fa76418cb3c99"}]}]}],"channels":["EMAIL","SMS","PUSH"],"consent_mode":"MARKETING"}}}}}},"responses":{"200":{"description":"Audience preview generado exitosamente","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AudiencePreviewResponse"},"examples":{"success":{"summary":"Preview de audiencia combinada (consent_mode=MARKETING)","value":{"success":true,"unique_total":1000,"eligible":{"email":612,"sms":430,"push":210},"eligible_any_channel":750,"ineligible_all_channels":250,"email_exclusions":{"no_email":120,"invalid_email":45,"opt_out":88,"unknown":135},"computed_at":"2026-07-17T09:15:32.000Z","data_as_of":"2026-07-16T03:00:00.000Z"}}}}}},"400":{"description":"Solicitud inválida","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"too_many_definitions":{"summary":"Más de 20 definiciones","value":{"success":false,"message":"Failed to compute audience preview","error":"Invalid request: definitions must contain at most 20 items (got 21)"}},"invalid_channel_value":{"summary":"Canal no soportado","value":{"success":false,"message":"Failed to compute audience preview","error":"Invalid request: unsupported channel \"WHATSAPP\" (expected one of EMAIL, SMS, PUSH)"}},"invalid_definition_index":{"summary":"Definición inválida (con índice)","value":{"success":false,"message":"Failed to compute audience preview","error":"Invalid definitions[1]: <detalle del error de estructura DSL>"}},"invalid_average_threshold":{"summary":"Umbral de media no decimal (se rechaza antes de consultar datos)","value":{"success":false,"message":"Failed to compute audience preview","error":"El umbral de la media del criterio 'average_spent' debe ser un número (recibido: \"0x10\")","code":"INVALID_AVERAGE_THRESHOLD"}}}}}},"403":{"$ref":"#/components/responses/DataAccessForbidden"},"500":{"$ref":"#/components/responses/InternalServerError"},"503":{"$ref":"#/components/responses/TenantHierarchyUnavailable"}},"x-code-samples":[{"lang":"JavaScript","label":"JavaScript (Fetch)","source":"// Vista previa de audiencia combinando 2 segmentos\nconst response = await fetch('https://api.nevent.es/segments/audience-preview', {\n  method: 'POST',\n  headers: {\n    'Authorization': 'Bearer YOUR_TOKEN',\n    'Content-Type': 'application/json'\n  },\n  body: JSON.stringify({\n    \"definitions\": [\n      {\n        \"stanzas\": [\n          {\n            \"id\": \"stanza_vip\",\n            \"criteria\": [\n              {\n                \"id\": \"crit_vip_spend\",\n                \"criterion_id\": \"total_spent\",\n                \"operator\": \"gte\",\n                \"value\": 500\n              }\n            ]\n          }\n        ]\n      }\n    ],\n    \"channels\": [\"EMAIL\", \"SMS\", \"PUSH\"],\n    \"consent_mode\": \"MARKETING\"\n  })\n});\n\nconst data = await response.json();\nconsole.log(data);"},{"lang":"Shell","label":"cURL","source":"# Vista previa de audiencia combinando 2 segmentos\ncurl -X POST https://api.nevent.es/segments/audience-preview \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"definitions\":[{\"stanzas\":[{\"id\":\"stanza_vip\",\"criteria\":[{\"id\":\"crit_vip_spend\",\"criterion_id\":\"total_spent\",\"operator\":\"gte\",\"value\":500}]}]}],\"channels\":[\"EMAIL\",\"SMS\",\"PUSH\"],\"consent_mode\":\"MARKETING\"}'"}]}},"/custom-fields/properties":{"get":{"tags":["Custom Fields"],"summary":"Listar campos personalizados disponibles","description":"Retorna todos los custom fields disponibles para el tenant autenticado.\n\n**Información incluida**:\n- `property_name`: Nombre técnico del campo\n- `data_type`: Tipo de la definición viva (TEXT, SELECT, LIST, NUMBER, BOOLEAN, DATE)\n- `display_name`: Nombre legible para UI\n- `total_records`: Cantidad de registros con este campo\n- `data_type_conflict` (opcional): tipos incompatibles del mismo nombre entre los tenants consultados\n\nUna entrada por campo. Un nombre sin definición viva no se ofrece.\n\n**Cache**: Resultados cacheados por 5 minutos para performance.\n\n**Uso**: Útil para construir UI de selección de custom fields en segmentación.\n","operationId":"getCustomFieldProperties","responses":{"200":{"description":"Lista de custom fields obtenida exitosamente","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomFieldPropertiesResponse"},"examples":{"success":{"summary":"Respuesta exitosa","value":{"success":true,"custom_fields":[{"property_name":"preferred_music_genre","data_type":"TEXT","display_name":"Preferred Music Genre","total_records":15420},{"property_name":"loyalty_points","data_type":"NUMBER","display_name":"Loyalty Points","total_records":8932},{"property_name":"newsletter_subscribed","data_type":"BOOLEAN","display_name":"Newsletter Subscribed","total_records":12051}]}}}}}},"403":{"$ref":"#/components/responses/DataAccessForbidden"},"500":{"$ref":"#/components/responses/InternalServerError"},"503":{"$ref":"#/components/responses/TenantHierarchyUnavailable"}},"x-code-samples":[{"lang":"JavaScript","label":"JavaScript (Fetch)","source":"// Listar campos personalizados disponibles\nconst response = await fetch('https://api.nevent.es/custom-fields/properties', {\n  method: 'GET',\n  headers: {\n    'Authorization': 'Bearer YOUR_TOKEN',\n    'Content-Type': 'application/json'\n  }\n});\n\nconst data = await response.json();\nconsole.log(data);"},{"lang":"Python","label":"Python (requests)","source":"# Listar campos personalizados disponibles\nimport requests\n\nresponse = requests.get(\n    'https://api.nevent.es/custom-fields/properties',\n    headers={'Authorization': 'Bearer YOUR_TOKEN'}\n)\n\ndata = response.json()\nprint(data)"},{"lang":"Shell","label":"cURL","source":"# Listar campos personalizados disponibles\ncurl -X GET https://api.nevent.es/custom-fields/properties \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\""},{"lang":"Java","label":"Java (HttpClient)","source":"// Listar campos personalizados disponibles\nHttpClient client = HttpClient.newHttpClient();\n\nHttpRequest request = HttpRequest.newBuilder()\n    .uri(URI.create(\"https://api.nevent.es/custom-fields/properties\"))\n    .header(\"Authorization\", \"Bearer YOUR_TOKEN\")\n    .header(\"Content-Type\", \"application/json\")\n    .GET(HttpRequest.BodyPublishers.noBody())\n    .build();\n\nHttpResponse<String> response = client.send(request,\n    HttpResponse.BodyHandlers.ofString());\nSystem.out.println(response.body());"}]}},"/custom-fields/properties/{propertyName}":{"get":{"tags":["Custom Fields"],"summary":"Obtener detalles de un campo personalizado","description":"Retorna información detallada sobre un custom field específico.\n\n**Incluye**:\n- Metadata del campo (nombre, tipo, total de registros)\n- Valores de muestra (por defecto 10, configurable con `?limit=N`)\n\n**Uso**: Para mostrar valores de ejemplo al usuario antes de crear filtro.\n","operationId":"getCustomFieldDetails","parameters":[{"name":"propertyName","in":"path","required":true,"description":"Nombre técnico del custom field","schema":{"type":"string"},"example":"preferred_music_genre"},{"name":"limit","in":"query","required":false,"description":"Número de valores de muestra a retornar","schema":{"type":"integer","default":10,"minimum":1,"maximum":100}}],"responses":{"200":{"description":"Detalles del campo obtenidos exitosamente","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomFieldDetailsResponse"},"examples":{"success":{"summary":"Campo encontrado","value":{"success":true,"field":{"property_name":"preferred_music_genre","data_type":"TEXT","display_name":"Preferred Music Genre","total_records":15420,"sample_values":["Rock","Pop","Jazz","Electronic","Hip Hop"]}}}}}}},"404":{"description":"Campo no encontrado","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"examples":{"not_found":{"summary":"Campo no existe","value":{"success":false,"message":"Custom field 'invalid_field' not found","error":"Field does not exist or has no data"}}}}}},"500":{"$ref":"#/components/responses/InternalServerError"}},"x-code-samples":[{"lang":"JavaScript","label":"JavaScript (Fetch)","source":"// Obtener detalles de un campo personalizado\nconst response = await fetch('https://api.nevent.es/custom-fields/properties/{propertyName}', {\n  method: 'GET',\n  headers: {\n    'Authorization': 'Bearer YOUR_TOKEN',\n    'Content-Type': 'application/json'\n  }\n});\n\nconst data = await response.json();\nconsole.log(data);"},{"lang":"Python","label":"Python (requests)","source":"# Obtener detalles de un campo personalizado\nimport requests\n\nresponse = requests.get(\n    'https://api.nevent.es/custom-fields/properties/{propertyName}',\n    headers={'Authorization': 'Bearer YOUR_TOKEN'}\n)\n\ndata = response.json()\nprint(data)"},{"lang":"Shell","label":"cURL","source":"# Obtener detalles de un campo personalizado\ncurl -X GET https://api.nevent.es/custom-fields/properties/{propertyName} \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\""},{"lang":"Java","label":"Java (HttpClient)","source":"// Obtener detalles de un campo personalizado\nHttpClient client = HttpClient.newHttpClient();\n\nHttpRequest request = HttpRequest.newBuilder()\n    .uri(URI.create(\"https://api.nevent.es/custom-fields/properties/{propertyName}\"))\n    .header(\"Authorization\", \"Bearer YOUR_TOKEN\")\n    .header(\"Content-Type\", \"application/json\")\n    .GET(HttpRequest.BodyPublishers.noBody())\n    .build();\n\nHttpResponse<String> response = client.send(request,\n    HttpResponse.BodyHandlers.ofString());\nSystem.out.println(response.body());"}]}},"/custom-fields/validate":{"post":{"tags":["Custom Fields"],"summary":"Validar existencia de campo personalizado","description":"Valida si un custom field existe y retorna su metadata.\n\n**Uso**: Validación antes de construir segmento con custom field.\n","operationId":"validateCustomField","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["property_name"],"properties":{"property_name":{"type":"string","description":"Nombre del custom field a validar","example":"preferred_music_genre"}}}}}},"responses":{"200":{"description":"Campo validado exitosamente","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomFieldValidateResponse"},"examples":{"valid":{"summary":"Campo existe","value":{"success":true,"field":{"property_name":"preferred_music_genre","data_type":"TEXT","exists":true,"display_name":"Preferred Music Genre"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"403":{"$ref":"#/components/responses/DataAccessForbidden"},"404":{"description":"Campo no encontrado","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"examples":{"not_found":{"summary":"Campo no existe","value":{"success":false,"message":"Custom field 'invalid_field' not found","error":"Field does not exist or has no data"}}}}}},"500":{"$ref":"#/components/responses/InternalServerError"},"503":{"$ref":"#/components/responses/TenantHierarchyUnavailable"}},"x-code-samples":[{"lang":"JavaScript","label":"JavaScript (Fetch)","source":"// Validar existencia de campo personalizado\nconst response = await fetch('https://api.nevent.es/custom-fields/validate', {\n  method: 'POST',\n  headers: {\n    'Authorization': 'Bearer YOUR_TOKEN',\n    'Content-Type': 'application/json'\n  },\n  body: JSON.stringify({\n    \"property_name\": \"preferred_genre\"\n  })\n});\n\nconst data = await response.json();\nconsole.log(data);"},{"lang":"Python","label":"Python (requests)","source":"# Validar existencia de campo personalizado\nimport requests\n\nresponse = requests.post(\n    'https://api.nevent.es/custom-fields/validate',\n    headers={'Authorization': 'Bearer YOUR_TOKEN'},\n    json={\n        \"property_name\": \"preferred_genre\"\n    }\n)\n\ndata = response.json()\nprint(data)"},{"lang":"Shell","label":"cURL","source":"# Validar existencia de campo personalizado\ncurl -X POST https://api.nevent.es/custom-fields/validate \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"property_name\":\"preferred_genre\"}'"},{"lang":"Java","label":"Java (HttpClient)","source":"// Validar existencia de campo personalizado\nHttpClient client = HttpClient.newHttpClient();\n\nHttpRequest request = HttpRequest.newBuilder()\n    .uri(URI.create(\"https://api.nevent.es/custom-fields/validate\"))\n    .header(\"Authorization\", \"Bearer YOUR_TOKEN\")\n    .header(\"Content-Type\", \"application/json\")\n    .POST(HttpRequest.BodyPublishers.ofString(jsonBody))\n    .build();\n\nString jsonBody = \"\"\"\n{\n  \"property_name\": \"preferred_genre\"\n}\n\"\"\";\n\nrequest.POST(HttpRequest.BodyPublishers.ofString(jsonBody));\n\nHttpResponse<String> response = client.send(request,\n    HttpResponse.BodyHandlers.ofString());\nSystem.out.println(response.body());"}]}},"/custom-fields/cache":{"delete":{"tags":["Custom Fields"],"summary":"Limpiar cache de custom fields","description":"Endpoint administrativo para limpiar el cache de custom fields.\n\n**Uso**: Después de agregar nuevos custom fields in the database.\n","operationId":"clearCustomFieldCache","responses":{"200":{"description":"Cache limpiado exitosamente","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string","example":"Custom fields cache cleared successfully"}}}}}},"500":{"$ref":"#/components/responses/InternalServerError"}},"x-code-samples":[{"lang":"JavaScript","label":"JavaScript (Fetch)","source":"// Limpiar cache de custom fields\nconst response = await fetch('https://api.nevent.es/custom-fields/cache', {\n  method: 'DELETE',\n  headers: {\n    'Authorization': 'Bearer YOUR_TOKEN',\n    'Content-Type': 'application/json'\n  }\n});\n\nconst data = await response.json();\nconsole.log(data);"},{"lang":"Python","label":"Python (requests)","source":"# Limpiar cache de custom fields\nimport requests\n\nresponse = requests.delete(\n    'https://api.nevent.es/custom-fields/cache',\n    headers={'Authorization': 'Bearer YOUR_TOKEN'}\n)\n\ndata = response.json()\nprint(data)"},{"lang":"Shell","label":"cURL","source":"# Limpiar cache de custom fields\ncurl -X DELETE https://api.nevent.es/custom-fields/cache \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\""},{"lang":"Java","label":"Java (HttpClient)","source":"// Limpiar cache de custom fields\nHttpClient client = HttpClient.newHttpClient();\n\nHttpRequest request = HttpRequest.newBuilder()\n    .uri(URI.create(\"https://api.nevent.es/custom-fields/cache\"))\n    .header(\"Authorization\", \"Bearer YOUR_TOKEN\")\n    .header(\"Content-Type\", \"application/json\")\n    .DELETE(HttpRequest.BodyPublishers.noBody())\n    .build();\n\nHttpResponse<String> response = client.send(request,\n    HttpResponse.BodyHandlers.ofString());\nSystem.out.println(response.body());"}]}},"/custom-fields/cache/stats":{"get":{"tags":["Custom Fields"],"summary":"Obtener estadísticas de cache","description":"Endpoint de debug para ver estadísticas del cache de custom fields.\n\n**Información retornada**:\n- Número de entradas en cache\n- Memoria total utilizada\n","operationId":"getCustomFieldCacheStats","responses":{"200":{"description":"Estadísticas obtenidas exitosamente","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"cache_stats":{"type":"object","properties":{"entries":{"type":"integer","description":"Número de entradas en cache","example":42},"total_memory_bytes":{"type":"integer","description":"Memoria total en bytes","example":15234},"total_memory_kb":{"type":"number","description":"Memoria total en kilobytes","example":14.88}}}}}}}},"500":{"$ref":"#/components/responses/InternalServerError"}},"x-code-samples":[{"lang":"JavaScript","label":"JavaScript (Fetch)","source":"// Obtener estadísticas de cache\nconst response = await fetch('https://api.nevent.es/custom-fields/cache/stats', {\n  method: 'GET',\n  headers: {\n    'Authorization': 'Bearer YOUR_TOKEN',\n    'Content-Type': 'application/json'\n  }\n});\n\nconst data = await response.json();\nconsole.log(data);"},{"lang":"Python","label":"Python (requests)","source":"# Obtener estadísticas de cache\nimport requests\n\nresponse = requests.get(\n    'https://api.nevent.es/custom-fields/cache/stats',\n    headers={'Authorization': 'Bearer YOUR_TOKEN'}\n)\n\ndata = response.json()\nprint(data)"},{"lang":"Shell","label":"cURL","source":"# Obtener estadísticas de cache\ncurl -X GET https://api.nevent.es/custom-fields/cache/stats \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\""},{"lang":"Java","label":"Java (HttpClient)","source":"// Obtener estadísticas de cache\nHttpClient client = HttpClient.newHttpClient();\n\nHttpRequest request = HttpRequest.newBuilder()\n    .uri(URI.create(\"https://api.nevent.es/custom-fields/cache/stats\"))\n    .header(\"Authorization\", \"Bearer YOUR_TOKEN\")\n    .header(\"Content-Type\", \"application/json\")\n    .GET(HttpRequest.BodyPublishers.noBody())\n    .build();\n\nHttpResponse<String> response = client.send(request,\n    HttpResponse.BodyHandlers.ofString());\nSystem.out.println(response.body());"}]}},"/analytics/health-check":{"get":{"tags":["Health Check"],"summary":"Verificación de estado del servicio","description":"Verifica la conectividad con database y el estado general del servicio Analytics.\n\nEste endpoint no requiere autenticación y puede usarse para monitoreo.\n","operationId":"getHealthCheck","security":[],"responses":{"200":{"description":"Estado del servicio y conectividad","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthCheckResponse"},"examples":{"healthy":{"summary":"Servicio funcionando correctamente","value":{"success":true,"data":{"status":"ok","database":{"connected":true,"projectId":"nevent-daf5f","datasets":["mart_nevent_dev","mart_nevent_prd"]},"timestamp":"2025-09-27T16:52:41.138Z"},"timestamp":"2025-09-27T16:52:41.138Z"}},"unhealthy":{"summary":"Error de conectividad","value":{"success":true,"data":{"status":"error","database":{"connected":false,"projectId":"nevent-daf5f"},"timestamp":"2025-09-27T16:52:41.138Z"},"timestamp":"2025-09-27T16:52:41.138Z"}}}}}}},"x-code-samples":[{"lang":"JavaScript","label":"JavaScript (Fetch)","source":"// Verificación de estado del servicio\nconst response = await fetch('https://api.nevent.es/analytics/health-check', {\n  method: 'GET',\n  headers: {\n    'Authorization': 'Bearer YOUR_TOKEN',\n    'Content-Type': 'application/json'\n  }\n});\n\nconst data = await response.json();\nconsole.log(data);"},{"lang":"Python","label":"Python (requests)","source":"# Verificación de estado del servicio\nimport requests\n\nresponse = requests.get(\n    'https://api.nevent.es/analytics/health-check',\n    headers={'Authorization': 'Bearer YOUR_TOKEN'}\n)\n\ndata = response.json()\nprint(data)"},{"lang":"Shell","label":"cURL","source":"# Verificación de estado del servicio\ncurl -X GET https://api.nevent.es/analytics/health-check \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\""},{"lang":"Java","label":"Java (HttpClient)","source":"// Verificación de estado del servicio\nHttpClient client = HttpClient.newHttpClient();\n\nHttpRequest request = HttpRequest.newBuilder()\n    .uri(URI.create(\"https://api.nevent.es/analytics/health-check\"))\n    .header(\"Authorization\", \"Bearer YOUR_TOKEN\")\n    .header(\"Content-Type\", \"application/json\")\n    .GET(HttpRequest.BodyPublishers.noBody())\n    .build();\n\nHttpResponse<String> response = client.send(request,\n    HttpResponse.BodyHandlers.ofString());\nSystem.out.println(response.body());"}]}},"/analytics/filters":{"post":{"tags":["Filters"],"summary":"Obtener valores únicos para filtros dinámicos","description":"Obtiene valores únicos de campos específicos para construir filtros dinámicos.\n\nEquivalente a `SELECT DISTINCT` en SQL o `db.collection.distinct()` en metadata store.\n\nLos valores se filtran automáticamente por tenant basado en el JWT token.\n","operationId":"getFilterValues","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FilterRequest"},"examples":{"single_collection":{"summary":"Valores de una colección","value":{"queries":[{"collection":"email_usage_daily","fields":["tenant_name","tier_name"]}]}},"multiple_collections":{"summary":"Valores de múltiples colecciones","value":{"queries":[{"collection":"email_usage_daily","fields":["tenant_name"]},{"collection":"purchases","fields":["status","payment_method"]}]}},"with_filters":{"summary":"Con filtros adicionales","value":{"queries":[{"collection":"email_usage_daily","fields":["tier_name"],"filters":[{"field":"send_date","operator":"gte","value":"2024-01-01"}]}]}}}}}},"responses":{"200":{"description":"Valores únicos encontrados","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"array","items":{"$ref":"#/components/schemas/FilterResponse"}},"timestamp":{"type":"string","format":"date-time"}}},"examples":{"success":{"summary":"Valores obtenidos correctamente","value":{"success":true,"data":[{"collection":"email_usage_daily","results":{"tenant_name":["Empresa A","Empresa B"],"tier_name":["Basic","Professional","Enterprise"]}}],"timestamp":"2025-09-27T16:52:31.481Z"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/DataAccessForbidden"},"500":{"$ref":"#/components/responses/InternalServerError"},"503":{"$ref":"#/components/responses/TenantHierarchyUnavailable"}},"x-code-samples":[{"lang":"JavaScript","label":"JavaScript (Fetch)","source":"// Obtener valores únicos para filtros dinámicos\nconst response = await fetch('https://api.nevent.es/analytics/filters', {\n  method: 'POST',\n  headers: {\n    'Authorization': 'Bearer YOUR_TOKEN',\n    'Content-Type': 'application/json'\n  }\n});\n\nconst data = await response.json();\nconsole.log(data);"},{"lang":"Python","label":"Python (requests)","source":"# Obtener valores únicos para filtros dinámicos\nimport requests\n\nresponse = requests.post(\n    'https://api.nevent.es/analytics/filters',\n    headers={'Authorization': 'Bearer YOUR_TOKEN'}\n)\n\ndata = response.json()\nprint(data)"},{"lang":"Shell","label":"cURL","source":"# Obtener valores únicos para filtros dinámicos\ncurl -X POST https://api.nevent.es/analytics/filters \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\""},{"lang":"Java","label":"Java (HttpClient)","source":"// Obtener valores únicos para filtros dinámicos\nHttpClient client = HttpClient.newHttpClient();\n\nHttpRequest request = HttpRequest.newBuilder()\n    .uri(URI.create(\"https://api.nevent.es/analytics/filters\"))\n    .header(\"Authorization\", \"Bearer YOUR_TOKEN\")\n    .header(\"Content-Type\", \"application/json\")\n    .POST(HttpRequest.BodyPublishers.noBody())\n    .build();\n\nHttpResponse<String> response = client.send(request,\n    HttpResponse.BodyHandlers.ofString());\nSystem.out.println(response.body());"}]}},"/analytics/query":{"post":{"tags":["Query"],"summary":"Ejecutar consulta analítica flexible","description":"Ejecuta consultas analíticas flexibles con agregaciones y filtros dinámicos.\n\nEl frontend puede definir:\n- **Dimensiones**: Campos para agrupar (GROUP BY)\n- **Métricas**: Campos para agregar (SUM, COUNT, AVG, etc.)\n- **Filtros**: Condiciones WHERE dinámicas\n- **Granularidad temporal**: Agregación por día, semana, mes, etc.\n- **Ordenamiento**: Campos y dirección\n- **Límites**: Número máximo de resultados\n\nTodas las consultas incluyen automáticamente filtrado por tenant.\n\n### Dry Run (estimación de coste)\n\nAñade `?dryRun=true` para ejecutar un **dry run nativo de BigQuery** que valida\nla sintaxis SQL y estima los bytes que se procesarían, **sin ejecutar la query**\n(coste = $0). Ideal para que el frontend muestre estimaciones de coste antes de\nejecutar queries pesadas.\n\n- Rate limit independiente: **200 req/min** (no consume cuota de queries normales)\n- Precio estimado: **$6.25/TB** (BigQuery on-demand pricing)\n- La respuesta incluye el SQL generado para depuración\n","operationId":"runAnalyticsQuery","parameters":[{"name":"dryRun","in":"query","required":false,"schema":{"type":"string","enum":["true","1"]},"description":"Cuando es `true` o `1`, ejecuta un dry run de BigQuery (validación de sintaxis\n+ estimación de coste) sin ejecutar la query real. La respuesta incluye\n`estimatedBytesProcessed`, `estimatedCostUSD` y el SQL generado.\nRate limit: 200 req/min (separado del límite de queries normales).\n","example":"true"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRequest"},"examples":{"simple_total":{"summary":"Total simple con filtros de fecha","value":{"collection":"email_usage_daily","metrics":[{"field":"emails_sent","operation":"sum","alias":"total_emails_sent"}],"filters":[{"field":"send_date","operator":"gte","value":"2024-07-01"},{"field":"send_date","operator":"lte","value":"2024-09-30"}]}},"monthly_aggregation":{"summary":"Agregación mensual con ordenamiento","value":{"collection":"email_usage_daily","dimensions":[{"field":"send_date","alias":"month"}],"metrics":[{"field":"emails_sent","operation":"sum","alias":"monthly_emails_sent"}],"timeGranularity":"month","filters":[{"field":"send_date","operator":"gte","value":"2024-01-01"}],"sort":{"field":"month","order":"asc"}}},"complex_analysis":{"summary":"Análisis complejo multi-dimensional","value":{"collection":"email_usage_daily","dimensions":[{"field":"tier_name","alias":"tier"},{"field":"send_date","alias":"month"}],"metrics":[{"field":"emails_sent","operation":"sum","alias":"total_emails"},{"field":"*","operation":"count","alias":"records_count"}],"timeGranularity":"month","filters":[{"field":"send_date","operator":"between","value":["2024-01-01","2024-12-31"]},{"field":"tier_name","operator":"in","value":["Professional","Enterprise"]}],"having":[{"field":"total_emails","operator":"gte","value":1000}],"sort":{"field":"total_emails","order":"desc"},"limit":100}}}}}},"responses":{"200":{"description":"Consulta ejecutada exitosamente","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"$ref":"#/components/schemas/QueryResponse"},"timestamp":{"type":"string","format":"date-time"}}},"examples":{"simple_result":{"summary":"Resultado de consulta simple","value":{"success":true,"data":{"data":[{"total_emails_sent":15420}],"metadata":{"totalRows":1,"executionTime":1263,"query":"SELECT SUM(`emails_sent`) as `total_emails_sent` FROM `nevent-daf5f.mart_nevent_dev.email_usage_daily` WHERE `tenant_id` = '72ef6ce8-ebed-44a0-bf75-0c36a4c11621' AND `send_date` >= DATE('2024-07-01') AND `send_date` <= DATE('2024-09-30')"}},"timestamp":"2025-09-27T16:51:49.868Z"}},"monthly_result":{"summary":"Resultado de agregación mensual","value":{"success":true,"data":{"data":[{"month":"2024-01-01","monthly_emails_sent":5420},{"month":"2024-02-01","monthly_emails_sent":6120},{"month":"2024-03-01","monthly_emails_sent":3880}],"metadata":{"totalRows":3,"executionTime":684,"query":"SELECT DATE_TRUNC(DATE(`send_date`), MONTH) as `month`, SUM(`emails_sent`) as `monthly_emails_sent` FROM `nevent-daf5f.mart_nevent_dev.email_usage_daily` WHERE `tenant_id` = '72ef6ce8-ebed-44a0-bf75-0c36a4c11621' AND `send_date` >= DATE('2024-01-01') GROUP BY 1 ORDER BY `month` ASC"}},"timestamp":"2025-09-27T16:52:18.754Z"}},"dry_run_result":{"summary":"Resultado de dry run (estimación de coste)","value":{"success":true,"dryRun":true,"data":{"valid":true,"estimatedBytesProcessed":1258291200,"estimatedBytesProcessedFormatted":"1.17 GB","estimatedCostUSD":0.007153,"query":"SELECT `event_id`, SUM(`total_price`) AS `revenue` FROM `nevent-daf5f.mart_nevent_dev.purchases` WHERE `tenant_id` IN ('aabbccddeeff00112233aabb') GROUP BY `event_id`"},"timestamp":"2026-04-18T10:00:00.000Z"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/DataAccessForbidden"},"500":{"$ref":"#/components/responses/InternalServerError"},"503":{"$ref":"#/components/responses/TenantHierarchyUnavailable"}},"x-code-samples":[{"lang":"JavaScript","label":"JavaScript (Fetch)","source":"// Ejecutar consulta analítica flexible\nconst response = await fetch('https://api.nevent.es/analytics/query', {\n  method: 'POST',\n  headers: {\n    'Authorization': 'Bearer YOUR_TOKEN',\n    'Content-Type': 'application/json'\n  },\n  body: JSON.stringify({\n    \"collection\": \"purchases\",\n    \"dimensions\": [\n      {\n        \"field\": \"event_id\"\n      }\n    ],\n    \"metrics\": [\n      {\n        \"field\": \"total_price_eur\",\n        \"operation\": \"sum\",\n        \"alias\": \"revenue\"\n      }\n    ],\n    \"filters\": [\n      {\n        \"field\": \"status\",\n        \"operator\": \"eq\",\n        \"value\": \"completed\"\n      }\n    ],\n    \"timeRange\": {\n      \"start\": \"2025-01-01\",\n      \"end\": \"2025-01-31\"\n    },\n    \"sort\": {\n      \"field\": \"revenue\",\n      \"order\": \"desc\"\n    },\n    \"limit\": 10\n  })\n});\n\nconst data = await response.json();\nconsole.log(data);"},{"lang":"Python","label":"Python (requests)","source":"# Ejecutar consulta analítica flexible\nimport requests\n\nresponse = requests.post(\n    'https://api.nevent.es/analytics/query',\n    headers={'Authorization': 'Bearer YOUR_TOKEN'},\n    json={\n        \"collection\": \"purchases\",\n        \"dimensions\": [\n            {\n                \"field\": \"event_id\"\n            }\n        ],\n        \"metrics\": [\n            {\n                \"field\": \"total_price_eur\",\n                \"operation\": \"sum\",\n                \"alias\": \"revenue\"\n            }\n        ],\n        \"filters\": [\n            {\n                \"field\": \"status\",\n                \"operator\": \"eq\",\n                \"value\": \"completed\"\n            }\n        ],\n        \"timeRange\": {\n            \"start\": \"2025-01-01\",\n            \"end\": \"2025-01-31\"\n        },\n        \"sort\": {\n            \"field\": \"revenue\",\n            \"order\": \"desc\"\n        },\n        \"limit\": 10\n    }\n)\n\ndata = response.json()\nprint(data)"},{"lang":"Shell","label":"cURL","source":"# Ejecutar consulta analítica flexible\ncurl -X POST https://api.nevent.es/analytics/query \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"collection\":\"purchases\",\"dimensions\":[{\"field\":\"event_id\"}],\"metrics\":[{\"field\":\"total_price_eur\",\"operation\":\"sum\",\"alias\":\"revenue\"}],\"filters\":[{\"field\":\"state\",\"operator\":\"eq\",\"value\":\"SUCCEEDED\"}],\"timeRange\":{\"start\":\"2025-01-01\",\"end\":\"2025-01-31\"},\"sort\":{\"field\":\"revenue\",\"order\":\"desc\"},\"limit\":10}'"},{"lang":"Java","label":"Java (HttpClient)","source":"// Ejecutar consulta analítica flexible\nHttpClient client = HttpClient.newHttpClient();\n\nHttpRequest request = HttpRequest.newBuilder()\n    .uri(URI.create(\"https://api.nevent.es/analytics/query\"))\n    .header(\"Authorization\", \"Bearer YOUR_TOKEN\")\n    .header(\"Content-Type\", \"application/json\")\n    .POST(HttpRequest.BodyPublishers.ofString(jsonBody))\n    .build();\n\nString jsonBody = \"\"\"\n{\n  \"collection\": \"purchases\",\n  \"dimensions\": [\n    {\n      \"field\": \"event_id\"\n    }\n  ],\n  \"metrics\": [\n    {\n      \"field\": \"total_price_eur\",\n      \"operation\": \"sum\",\n      \"alias\": \"revenue\"\n    }\n  ],\n  \"filters\": [\n    {\n      \"field\": \"status\",\n      \"operator\": \"eq\",\n      \"value\": \"completed\"\n    }\n  ],\n  \"timeRange\": {\n    \"start\": \"2025-01-01\",\n    \"end\": \"2025-01-31\"\n  },\n  \"sort\": {\n    \"field\": \"revenue\",\n    \"order\": \"desc\"\n  },\n  \"limit\": 10\n}\n\"\"\";\n\nrequest.POST(HttpRequest.BodyPublishers.ofString(jsonBody));\n\nHttpResponse<String> response = client.send(request,\n    HttpResponse.BodyHandlers.ofString());\nSystem.out.println(response.body());"}]}},"/analytics/user-interactions":{"post":{"tags":["Query"],"summary":"Historial de interacciones de email de un contacto","description":"Devuelve la línea temporal de interacciones de email de un contacto\n(envíos, aperturas, clics, rebotes...) para la pestaña de historial de\ninteracciones del CRM.\n\nEl aislamiento por tenant se aplica automáticamente con la jerarquía\ndel JWT (tenant propio + descendientes). Un SUPERADMIN puede consultar\notro tenant enviando la cabecera `x-tenant-id` (se expande a ese tenant\nmás su jerarquía).\n\nPor defecto devuelve el historial completo (máximo 10000 filas, más\nrecientes primero). El parámetro opcional `sinceDays` limita la ventana\ntemporal y abarata la consulta.\n","operationId":"getUserInteractions","security":[{"bearerAuth":[]}],"parameters":[{"name":"x-tenant-id","in":"header","required":false,"schema":{"type":"string"},"description":"Solo SUPERADMIN — tenant alternativo a consultar (se expande a su jerarquía)"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["userId"],"properties":{"userId":{"type":"string","description":"ID del contacto (usuario del CRM)","example":"68388013e2226d79536e4ae2"},"sinceDays":{"type":"integer","minimum":1,"description":"Opcional — limitar el historial a los últimos N días","example":90}}}}}},"responses":{"200":{"description":"Historial de interacciones obtenido correctamente","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"array","items":{"type":"object","properties":{"date":{"type":"string","format":"date-time","example":"2026-08-30T10:00:00.000Z"},"channel":{"type":"string","example":"email"},"campaignId":{"type":"string","nullable":true,"example":"68a1b2c3d4e5f60718293a4b"},"campaignName":{"type":"string","nullable":true,"example":"Newsletter agosto"},"eventType":{"type":"string","example":"open"},"messageId":{"type":"string","nullable":true,"example":"0100019a1b2c3d4e-..."},"recipientEmail":{"type":"string","nullable":true,"example":"fan@example.com"}}}},"timestamp":{"type":"string","format":"date-time"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/DataAccessForbidden"},"500":{"$ref":"#/components/responses/InternalServerError"},"503":{"$ref":"#/components/responses/TenantHierarchyUnavailable"}}}},"/analytics/capabilities":{"get":{"tags":["Analytics Capabilities"],"summary":"Obtener capacidades completas de la Analytics API","description":"**Endpoint público para asistentes IA** - No requiere autenticación.\n\nRetorna todas las capacidades de la Analytics API incluyendo:\n- **Operadores disponibles** por tipo de dato (TEXT, NUMBER, DATE, BOOLEAN)\n- **Agregaciones disponibles** por tipo de dato\n- **Granularidades temporales** (hour, day, week, month, quarter, year, fiscalQuarter, fiscalYear)\n- **Tipos de groupBy** (dayOfWeek, hourOfDay, minuteOfHour, etc.)\n- **Tablas disponibles** con sus campos y tipos de datos\n- **Ejemplos de queries** comunes para cada caso de uso\n\n**Útil para**:\n- AI Assistants que necesitan construir queries Analytics API\n- Documentación automática de capacidades\n- Validación de queries antes de enviar\n- Autocompletado en UI\n","operationId":"getAnalyticsCapabilities","security":[],"responses":{"200":{"description":"Capacidades de Analytics API","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","properties":{"operators":{"type":"object","description":"Operadores por tipo de dato","additionalProperties":{"type":"array","items":{"type":"string"}}},"aggregations":{"type":"object","description":"Agregaciones por tipo de dato","additionalProperties":{"type":"array","items":{"type":"string"}}},"timeGranularities":{"type":"array","items":{"type":"string"},"description":"Granularidades temporales disponibles"},"groupByTypes":{"type":"array","items":{"type":"string"},"description":"Tipos de groupBy disponibles"},"tables":{"type":"object","description":"Tablas disponibles con metadata","additionalProperties":{"type":"object"}},"examples":{"type":"array","description":"Ejemplos de queries comunes","items":{"type":"object"}}}},"timestamp":{"type":"string","format":"date-time"}}},"examples":{"capabilities":{"summary":"Capacidades completas","value":{"success":true,"data":{"operators":{"TEXT":["eq","neq","in","not_in","contains","not_contains","starts_with","ends_with","regex","is_null","is_not_null"],"NUMBER":["eq","neq","gt","gte","lt","lte","in","not_in","between","is_null","is_not_null"],"DATE":["eq","neq","gt","gte","lt","lte","between","before","after","is_null","is_not_null"],"BOOLEAN":["eq","neq","exists"]},"aggregations":{"TEXT":["count","count_distinct","string_agg","approx_top_count"],"NUMBER":["sum","avg","count","min","max","count_distinct","median","percentile_25","percentile_50","percentile_75","percentile_90","percentile_95","sum_if","avg_if","count_if","approx_top_count"],"DATE":["count","min","max","count_distinct","count_if","approx_top_count","date_diff","date_add","date_sub"],"TIMESTAMP":["count","min","max","count_distinct","count_if","approx_top_count","date_diff","date_add","date_sub"],"BOOLEAN":["count","count_distinct","count_if","approx_top_count"],"ARRAY":["count","count_distinct","approx_top_count"]},"timeGranularities":["minute","hour","day","week","month","quarter","year","fiscalQuarter","fiscalYear"],"groupByTypes":["minuteOfHour","hourOfDay","dayOfWeek","weekOfYear","day","week","month","quarter","year","fiscalQuarter","fiscalYear"],"tables":{"purchases":{"description":"Purchase transactions","timeField":"purchased_at","tenantField":"tenant_id"}}},"timestamp":"2025-12-27T10:00:00.000Z"}}}}}},"500":{"$ref":"#/components/responses/InternalServerError"}},"x-code-samples":[{"lang":"JavaScript","label":"JavaScript (Fetch)","source":"// Obtener capacidades completas de la Analytics API\nconst response = await fetch('https://api.nevent.es/analytics/capabilities', {\n  method: 'GET',\n  headers: {\n    'Authorization': 'Bearer YOUR_TOKEN',\n    'Content-Type': 'application/json'\n  }\n});\n\nconst data = await response.json();\nconsole.log(data);"},{"lang":"Python","label":"Python (requests)","source":"# Obtener capacidades completas de la Analytics API\nimport requests\n\nresponse = requests.get(\n    'https://api.nevent.es/analytics/capabilities',\n    headers={'Authorization': 'Bearer YOUR_TOKEN'}\n)\n\ndata = response.json()\nprint(data)"},{"lang":"Shell","label":"cURL","source":"# Obtener capacidades completas de la Analytics API\ncurl -X GET https://api.nevent.es/analytics/capabilities \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\""},{"lang":"Java","label":"Java (HttpClient)","source":"// Obtener capacidades completas de la Analytics API\nHttpClient client = HttpClient.newHttpClient();\n\nHttpRequest request = HttpRequest.newBuilder()\n    .uri(URI.create(\"https://api.nevent.es/analytics/capabilities\"))\n    .header(\"Authorization\", \"Bearer YOUR_TOKEN\")\n    .header(\"Content-Type\", \"application/json\")\n    .GET(HttpRequest.BodyPublishers.noBody())\n    .build();\n\nHttpResponse<String> response = client.send(request,\n    HttpResponse.BodyHandlers.ofString());\nSystem.out.println(response.body());"}]}},"/analytics/capabilities/{table}":{"get":{"tags":["Analytics Capabilities"],"summary":"Obtener capacidades de una tabla específica","description":"**Endpoint público para asistentes IA** - No requiere autenticación.\n\nRetorna información detallada sobre una tabla específica:\n- **Nombre y descripción** de la tabla\n- **Campo temporal principal** (para filtros de fecha)\n- **Campo de tenant** (para filtrado automático)\n- **Lista completa de campos** con:\n  - Tipo de dato (TEXT, NUMBER, DATE, BOOLEAN)\n  - Operadores válidos para ese campo\n  - Agregaciones válidas para ese campo\n  - Descripción del campo\n\n**Útil para**:\n- Construir queries específicas para una tabla\n- Validar campos antes de usar en query\n- Autocompletado contextual en UI\n","operationId":"getTableCapabilities","security":[],"parameters":[{"name":"table","in":"path","required":true,"description":"Nombre de la tabla","schema":{"type":"string"},"example":"purchases"}],"responses":{"200":{"description":"Capacidades de la tabla","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","properties":{"table":{"type":"string","description":"Nombre de la tabla"},"description":{"type":"string","description":"Descripción de la tabla"},"timeField":{"type":"string","nullable":true,"description":"Campo temporal principal. `null` si la tabla no tiene campo temporal: entonces\n`timeRange`, `timeGranularity`, `groupBy` y las comparativas de periodos dan\n400 salvo que una dimensión temporal de la consulta aporte el campo.\n"},"tenantField":{"type":"string","description":"Campo de tenant"},"fields":{"type":"array","description":"Campos de la tabla","items":{"type":"object","properties":{"name":{"type":"string"},"type":{"type":"string"},"operators":{"type":"array","items":{"type":"string"}},"aggregations":{"type":"array","items":{"type":"string"}},"description":{"type":"string"}}}}}},"timestamp":{"type":"string","format":"date-time"}}},"examples":{"table_capabilities":{"summary":"Capacidades de tabla purchases","value":{"success":true,"data":{"table":"purchases","description":"Purchase transactions","timeField":"purchased_at","tenantField":"tenant_id","fields":[{"name":"id","type":"TEXT","operators":["eq","neq","in","not_in"],"aggregations":["count","count_distinct"],"description":"Purchase ID"},{"name":"total_price_eur","type":"NUMBER","operators":["eq","neq","gt","gte","lt","lte","between"],"aggregations":["sum","avg","min","max","count","median"],"description":"Total price in cents"},{"name":"purchased_at","type":"DATE","operators":["eq","neq","gt","gte","lt","lte","between","before","after"],"aggregations":["count","min","max"],"description":"Purchase timestamp"}]},"timestamp":"2025-12-27T10:00:00.000Z"}}}}}},"404":{"description":"Tabla no encontrada","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"500":{"$ref":"#/components/responses/InternalServerError"}},"x-code-samples":[{"lang":"JavaScript","label":"JavaScript (Fetch)","source":"// Obtener capacidades de una tabla específica\nconst response = await fetch('https://api.nevent.es/analytics/capabilities/{table}', {\n  method: 'GET',\n  headers: {\n    'Authorization': 'Bearer YOUR_TOKEN',\n    'Content-Type': 'application/json'\n  }\n});\n\nconst data = await response.json();\nconsole.log(data);"},{"lang":"Python","label":"Python (requests)","source":"# Obtener capacidades de una tabla específica\nimport requests\n\nresponse = requests.get(\n    'https://api.nevent.es/analytics/capabilities/{table}',\n    headers={'Authorization': 'Bearer YOUR_TOKEN'}\n)\n\ndata = response.json()\nprint(data)"},{"lang":"Shell","label":"cURL","source":"# Obtener capacidades de una tabla específica\ncurl -X GET https://api.nevent.es/analytics/capabilities/{table} \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\""},{"lang":"Java","label":"Java (HttpClient)","source":"// Obtener capacidades de una tabla específica\nHttpClient client = HttpClient.newHttpClient();\n\nHttpRequest request = HttpRequest.newBuilder()\n    .uri(URI.create(\"https://api.nevent.es/analytics/capabilities/{table}\"))\n    .header(\"Authorization\", \"Bearer YOUR_TOKEN\")\n    .header(\"Content-Type\", \"application/json\")\n    .GET(HttpRequest.BodyPublishers.noBody())\n    .build();\n\nHttpResponse<String> response = client.send(request,\n    HttpResponse.BodyHandlers.ofString());\nSystem.out.println(response.body());"}]}},"/analytics/schema":{"get":{"tags":["Schema Discovery"],"summary":"Listar tablas disponibles (ADMIN only)","description":"**Requiere autenticación y rol ADMIN/SUPERADMIN**.\n\nRetorna lista de tablas disponibles in the database que están en el whitelist.\n\n**Seguridad**:\n- Whitelist: Solo 14 tablas permitidas\n- RBAC: Requiere rol ADMIN o SUPERADMIN\n- Rate Limiting: 10 requests/hour por tenant\n- Audit Logging: Todos los accesos son registrados\n\n**Tablas permitidas**: tickets, user_tenants, purchases, purchase_tickets,\npurchase_supplements, purchase_complements, events, campaigns,\nemail_usage_daily, user_custom_fields, user_labels, dim_dates, short_url_clicks.\n`users` (tabla global sin `tenant_id`) e `interaction` (no existe en el mart) no se sirven.\n","operationId":"listTables","responses":{"200":{"description":"Lista de tablas disponibles","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"tables":{"type":"array","items":{"type":"string"},"description":"Lista de nombres de tablas"},"count":{"type":"integer","description":"Número total de tablas"}}},"examples":{"tables_list":{"summary":"Lista de tablas","value":{"success":true,"tables":["tickets","purchases","events","user_tenants","campaigns","short_url_clicks"],"count":6}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Acceso denegado - Requiere rol ADMIN/SUPERADMIN","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"Rate limit excedido - Máximo 10 requests/hour","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"500":{"$ref":"#/components/responses/InternalServerError"}},"x-code-samples":[{"lang":"JavaScript","label":"JavaScript (Fetch)","source":"// Listar tablas disponibles (ADMIN only)\nconst response = await fetch('https://api.nevent.es/analytics/schema', {\n  method: 'GET',\n  headers: {\n    'Authorization': 'Bearer YOUR_TOKEN',\n    'Content-Type': 'application/json'\n  }\n});\n\nconst data = await response.json();\nconsole.log(data);"},{"lang":"Python","label":"Python (requests)","source":"# Listar tablas disponibles (ADMIN only)\nimport requests\n\nresponse = requests.get(\n    'https://api.nevent.es/analytics/schema',\n    headers={'Authorization': 'Bearer YOUR_TOKEN'}\n)\n\ndata = response.json()\nprint(data)"},{"lang":"Shell","label":"cURL","source":"# Listar tablas disponibles (ADMIN only)\ncurl -X GET https://api.nevent.es/analytics/schema \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\""},{"lang":"Java","label":"Java (HttpClient)","source":"// Listar tablas disponibles (ADMIN only)\nHttpClient client = HttpClient.newHttpClient();\n\nHttpRequest request = HttpRequest.newBuilder()\n    .uri(URI.create(\"https://api.nevent.es/analytics/schema\"))\n    .header(\"Authorization\", \"Bearer YOUR_TOKEN\")\n    .header(\"Content-Type\", \"application/json\")\n    .GET(HttpRequest.BodyPublishers.noBody())\n    .build();\n\nHttpResponse<String> response = client.send(request,\n    HttpResponse.BodyHandlers.ofString());\nSystem.out.println(response.body());"}]}},"/analytics/schema/{table}":{"get":{"tags":["Schema Discovery"],"summary":"Obtener schema de tabla específica (ADMIN only)","description":"**Requiere autenticación y rol ADMIN/SUPERADMIN**.\n\nRetorna el schema completo de una tabla específica desde schema metadata.\n\n**Incluye**:\n- Nombre de todos los campos\n- Tipo de dato de cada campo\n- Si el campo acepta NULL\n\n**Columnas sensibles filtradas**: password, hashed_password, api_key, access_token,\ncredit_card_last4, payment_method_token, ssn, fiscal_id, tax_id\n\n**Cache**: 1 hora TTL por tabla\n","operationId":"getTableSchema","parameters":[{"name":"table","in":"path","required":true,"description":"Nombre de la tabla","schema":{"type":"string"},"example":"purchases"}],"responses":{"200":{"description":"Schema de la tabla","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"table":{"type":"string","description":"Nombre de la tabla"},"columns":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"type":{"type":"string"},"nullable":{"type":"boolean"}}}},"column_count":{"type":"integer"}}},"examples":{"table_schema":{"summary":"Schema de purchases","value":{"success":true,"table":"purchases","columns":[{"name":"id","type":"STRING","nullable":false},{"name":"total_price_eur","type":"INTEGER","nullable":true},{"name":"purchased_at","type":"TIMESTAMP","nullable":false},{"name":"tenant_id","type":"STRING","nullable":false}],"column_count":4}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Acceso denegado - Requiere rol ADMIN/SUPERADMIN","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"Tabla no encontrada o no permitida","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"Rate limit excedido","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"500":{"$ref":"#/components/responses/InternalServerError"}},"x-code-samples":[{"lang":"JavaScript","label":"JavaScript (Fetch)","source":"// Obtener schema de tabla específica (ADMIN only)\nconst response = await fetch('https://api.nevent.es/analytics/schema/{table}', {\n  method: 'GET',\n  headers: {\n    'Authorization': 'Bearer YOUR_TOKEN',\n    'Content-Type': 'application/json'\n  }\n});\n\nconst data = await response.json();\nconsole.log(data);"},{"lang":"Python","label":"Python (requests)","source":"# Obtener schema de tabla específica (ADMIN only)\nimport requests\n\nresponse = requests.get(\n    'https://api.nevent.es/analytics/schema/{table}',\n    headers={'Authorization': 'Bearer YOUR_TOKEN'}\n)\n\ndata = response.json()\nprint(data)"},{"lang":"Shell","label":"cURL","source":"# Obtener schema de tabla específica (ADMIN only)\ncurl -X GET https://api.nevent.es/analytics/schema/{table} \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\""},{"lang":"Java","label":"Java (HttpClient)","source":"// Obtener schema de tabla específica (ADMIN only)\nHttpClient client = HttpClient.newHttpClient();\n\nHttpRequest request = HttpRequest.newBuilder()\n    .uri(URI.create(\"https://api.nevent.es/analytics/schema/{table}\"))\n    .header(\"Authorization\", \"Bearer YOUR_TOKEN\")\n    .header(\"Content-Type\", \"application/json\")\n    .GET(HttpRequest.BodyPublishers.noBody())\n    .build();\n\nHttpResponse<String> response = client.send(request,\n    HttpResponse.BodyHandlers.ofString());\nSystem.out.println(response.body());"}]}},"/analytics/schema/cache":{"delete":{"tags":["Schema Discovery"],"summary":"Invalidar cache de schema (ADMIN only)","description":"**Requiere autenticación y rol ADMIN/SUPERADMIN**.\n\nInvalida el cache de schema para una tabla específica o todas las tablas.\n\n**Query params**:\n- `table` (opcional): Nombre de tabla específica. Si no se provee, limpia todo el cache.\n","operationId":"clearSchemaCache","parameters":[{"name":"table","in":"query","required":false,"description":"Tabla específica (opcional, limpia todas si se omite)","schema":{"type":"string"},"example":"purchases"}],"responses":{"200":{"description":"Cache invalidado exitosamente","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string"},"table":{"type":"string"}}},"examples":{"specific_table":{"summary":"Cache de tabla específica limpiado","value":{"success":true,"message":"Schema cache cleared successfully","table":"purchases"}},"all_tables":{"summary":"Todo el cache limpiado","value":{"success":true,"message":"All schema cache cleared successfully"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Acceso denegado","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"500":{"$ref":"#/components/responses/InternalServerError"}},"x-code-samples":[{"lang":"JavaScript","label":"JavaScript (Fetch)","source":"// Invalidar cache de schema (ADMIN only)\nconst response = await fetch('https://api.nevent.es/analytics/schema/cache', {\n  method: 'DELETE',\n  headers: {\n    'Authorization': 'Bearer YOUR_TOKEN',\n    'Content-Type': 'application/json'\n  }\n});\n\nconst data = await response.json();\nconsole.log(data);"},{"lang":"Python","label":"Python (requests)","source":"# Invalidar cache de schema (ADMIN only)\nimport requests\n\nresponse = requests.delete(\n    'https://api.nevent.es/analytics/schema/cache',\n    headers={'Authorization': 'Bearer YOUR_TOKEN'}\n)\n\ndata = response.json()\nprint(data)"},{"lang":"Shell","label":"cURL","source":"# Invalidar cache de schema (ADMIN only)\ncurl -X DELETE https://api.nevent.es/analytics/schema/cache \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\""},{"lang":"Java","label":"Java (HttpClient)","source":"// Invalidar cache de schema (ADMIN only)\nHttpClient client = HttpClient.newHttpClient();\n\nHttpRequest request = HttpRequest.newBuilder()\n    .uri(URI.create(\"https://api.nevent.es/analytics/schema/cache\"))\n    .header(\"Authorization\", \"Bearer YOUR_TOKEN\")\n    .header(\"Content-Type\", \"application/json\")\n    .DELETE(HttpRequest.BodyPublishers.noBody())\n    .build();\n\nHttpResponse<String> response = client.send(request,\n    HttpResponse.BodyHandlers.ofString());\nSystem.out.println(response.body());"}]}},"/analytics/schema/cache/stats":{"get":{"tags":["Schema Discovery"],"summary":"Obtener estadísticas de cache (DEBUG)","description":"**Requiere autenticación y rol ADMIN/SUPERADMIN**.\n\nEndpoint de debugging para ver estadísticas del cache de schemas.\n\n**Retorna**:\n- Número de entradas en cache\n- Memoria total utilizada\n","operationId":"getSchemaCacheStats","responses":{"200":{"description":"Estadísticas del cache","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"cache_stats":{"type":"object","properties":{"entries":{"type":"integer"},"total_memory_bytes":{"type":"integer"},"total_memory_kb":{"type":"number"}}}}},"examples":{"stats":{"summary":"Estadísticas de cache","value":{"success":true,"cache_stats":{"entries":8,"total_memory_bytes":24576,"total_memory_kb":24}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Acceso denegado","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"500":{"$ref":"#/components/responses/InternalServerError"}},"x-code-samples":[{"lang":"JavaScript","label":"JavaScript (Fetch)","source":"// Obtener estadísticas de cache (DEBUG)\nconst response = await fetch('https://api.nevent.es/analytics/schema/cache/stats', {\n  method: 'GET',\n  headers: {\n    'Authorization': 'Bearer YOUR_TOKEN',\n    'Content-Type': 'application/json'\n  }\n});\n\nconst data = await response.json();\nconsole.log(data);"},{"lang":"Python","label":"Python (requests)","source":"# Obtener estadísticas de cache (DEBUG)\nimport requests\n\nresponse = requests.get(\n    'https://api.nevent.es/analytics/schema/cache/stats',\n    headers={'Authorization': 'Bearer YOUR_TOKEN'}\n)\n\ndata = response.json()\nprint(data)"},{"lang":"Shell","label":"cURL","source":"# Obtener estadísticas de cache (DEBUG)\ncurl -X GET https://api.nevent.es/analytics/schema/cache/stats \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\""},{"lang":"Java","label":"Java (HttpClient)","source":"// Obtener estadísticas de cache (DEBUG)\nHttpClient client = HttpClient.newHttpClient();\n\nHttpRequest request = HttpRequest.newBuilder()\n    .uri(URI.create(\"https://api.nevent.es/analytics/schema/cache/stats\"))\n    .header(\"Authorization\", \"Bearer YOUR_TOKEN\")\n    .header(\"Content-Type\", \"application/json\")\n    .GET(HttpRequest.BodyPublishers.noBody())\n    .build();\n\nHttpResponse<String> response = client.send(request,\n    HttpResponse.BodyHandlers.ofString());\nSystem.out.println(response.body());"}]}},"/import-sync/readiness":{"post":{"tags":["Import Sync"],"summary":"Comprobar si un lote importado ya está disponible para segmentar","description":"Endpoint servicio-a-servicio consumido por el reconciliador de importaciones de\nnev-api. **No acepta JWT de usuario.**\n\nDemuestra, usuario a usuario y campo a campo, que los datos aceptados por una\nimportación han llegado al mart que lee la segmentación. Un recuento de filas o una\nventana temporal no lo demuestran: los satisface igualmente un dato que nunca cambió.\n\nPara cada entrada se compara la huella que nev-api calcula desde el documento ACTUAL\nde origen con la que BigQuery calcula desde la fila del mart, usando la misma\ncodificación canónica (`n:` si el valor es nulo o ausente, `s:` + el valor en caso\ncontrario; SHA-256 sobre bytes UTF-8 en hexadecimal minúscula).\n\nLo que se demuestra en campos personalizados es la **proyección que el consumidor\nlee**: `property_name`, `data_type` y `typed_value` no nulos, y la huella de\n`typed_value` igual a la esperada. `raw_value` no es prueba: coincide en casos que el\nselector no puede mostrar (un valor LIST cuya extracción escalar deja `typed_value`\nnulo), y darlo por bueno certificaba campos que la segmentación no puede usar.\n\nContrato opcional v2 (`contractVersion: 2`): prueba histórica de un candidato fijo,\nidentificado por `candidateRevision`. Exige versión exacta y la misma imagen en\nusers/user_tenants, valores tipados y marcadores de campos personalizados, y presencia\no ausencia de cada etiqueta del ámbito por fila. No certifica escrituras futuras.\nUna imagen posterior devuelve `candidate_superseded`, nunca READY automático.\nLa respuesta devuelve `proofs` para TODOS los candidatos y su revisión recibida.\nFinalizar requiere lote vacío, `bumpCatalogVersionOnReady: true` y `finalizationId`\nestable por job/época; un replay reutiliza el recibo de catálogo transaccional.\n\nReglas que no admiten atajo:\n\n- Una edición posterior no exime la prueba: nev-api recalcula la expectativa, así que\n  la edición cambia lo que hay que demostrar.\n- Un valor marcado como ambiguo por la vista resuelta queda pendiente aunque coincida.\n- Un valor presente pero inutilizable (metadatos o `typed_value` nulos) queda pendiente\n  con `field:cf:<id>:unusable`; nunca READY.\n- La ausencia también es una expectativa: si el origen no tiene valor (huella de `n:`)\n  y el mart no tiene fila, está consumido. Si el mart conserva un valor que el origen\n  ya borró, queda pendiente con `field:cf:<id>:stale`.\n- Un borrado de usuario sí es terminal: si el mart marca el usuario como borrado no\n  queda nada que demostrar sobre sus campos.\n\nEl lote está listo cuando `pending` es 0. `missing` devuelve una entrada por cada\nprueba fallida, de modo que un mismo usuario puede aparecer varias veces.\n","operationId":"checkImportSyncReadiness","security":[{"serviceAuth":[]}],"parameters":[{"name":"x-service","in":"header","required":true,"schema":{"type":"string","enum":["nev-api"]},"description":"Identifica qué servicio interno llama"},{"name":"x-tenant-id","in":"header","required":true,"schema":{"type":"string"},"description":"Tenant sobre el que se comprueba (nunca se lee del body)"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["jobId","chunkIndex","entries"],"properties":{"contractVersion":{"type":"integer","enum":[2],"description":"Opcional; su ausencia conserva el contrato legacy."},"finalizationId":{"type":"string","maxLength":160,"description":"Identidad estable job/época para finalizar v2 de forma idempotente."},"jobId":{"type":"string","minLength":1,"description":"Job de importación al que pertenece el lote. Obligatorio y no vacío: junto\ncon `chunkIndex` es lo que permite rastrear la respuesta hasta su lote en\nCloud Logging. Se rechaza con 400 si falta o no es una cadena con contenido.\n","example":"68a1b2c3d4e5f60718293a4b"},"chunkIndex":{"type":"integer","minimum":0,"description":"Índice del lote dentro del manifiesto. Obligatorio, entero y no negativo\n(0 es el primer lote). Se rechaza con 400 en cualquier otro caso.\n","example":0},"importLabelId":{"type":"string","nullable":true,"description":"Etiqueta aplicada por la importación; si viene, se exige pertenencia","example":"68b2c3d4e5f60718293a4b5c"},"bumpCatalogVersionOnReady":{"type":"boolean","default":false,"description":"Incrementa la versión de catálogo del tenant si el lote está listo. La\ninvalidación es un paso de job completo, no de lote: llega una sola vez,\ncon `entries` vacío, cuando todos los lotes están READY.\n"},"entries":{"type":"array","maxItems":1000,"description":"Expectativas recalculadas desde los documentos actuales de origen. Puede\nir vacío **solo** con `bumpCatalogVersionOnReady: true`: es la\ninvalidación de catálogo a nivel de job, que llega cuando todos los lotes\nya están listos. Vacío sin esa petición se rechaza con 400.\n","items":{"type":"object","required":["userId","sourceVersion","deleted","fields"],"properties":{"candidateRevision":{"type":"string","maxLength":128,"description":"Obligatoria en v2; identidad inmutable del candidato, devuelta en su prueba."},"proofScope":{"type":"string","enum":["user-document-derived"],"description":"Obligatorio en v2. Certifica los insumos del documento de usuario; excluye la frescura de enriquecimientos de compras/clics."},"consumerFields":{"type":"object","minProperties":28,"maxProperties":28,"additionalProperties":{"type":"string","pattern":"^[a-fA-F0-9]{64}$"},"description":"Obligatorio en v2; mapa exacto de 28 huellas snake_case del contrato de imagen consumidora, incluidas preferencias y contactabilidad. created_at usa UTC YYYY-MM-DDTHH:mm:ss.SSSZ."},"labels":{"type":"object","maxProperties":100,"additionalProperties":{"type":"boolean"},"description":"V2; IDs de etiquetas importadas por fila y segmento; true exige presencia de la misma imagen, false exige ausencia."},"userId":{"type":"string","example":"68388013e2226d79536e4ae2"},"sourceVersion":{"type":"integer","format":"int64","description":"modifiedAt actual del documento de origen, en epoch ms","example":1789034400000},"deleted":{"type":"boolean","description":"Estado de borrado actual en origen"},"fields":{"type":"object","additionalProperties":{"type":"string"},"description":"Huella por campo mapeado. Claves `cf:<propertyDefinitionId>` para\ncampos personalizados y, para campos estándar, exactamente\n`std:email`, `std:name`, `std:phone`, `std:fiscalId` y `std:lang`:\nson los únicos que conservan una columna 1:1 en el mart. Cualquier\notra clave se rechaza con 400 nombrándola, en lugar de quedarse\npendiente para siempre sobre una prueba que nadie puede satisfacer.\n","example":{"std:email":"4d7ef18d30405721f3e8abef5d9b4c027327b3db72aea730ff57f2f9e3db1ab9","cf:68c1d2e3f40718293a4b5c6d":"5e5d3600c93ced8f8898c77693ce725b10015d89c1b57c6df8bb386dd06707bb"}}}}}}}}}},"responses":{"200":{"description":"Comprobación realizada","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","properties":{"contractVersion":{"type":"integer","enum":[2]},"queryStatistics":{"type":"object","description":"V2, estadísticas de la consulta única de esta página. Se omiten\nsi BigQuery no devuelve metadatos completos; ausencia no significa\ncoste cero. El consumidor deduplica por jobId antes de acumular.\nLos enteros se transmiten como cadenas decimales exactas.\n","required":["jobId","bytesProcessed","bytesBilled","cacheHit","slotMs"],"properties":{"jobId":{"type":"string"},"bytesProcessed":{"type":"string","pattern":"^[0-9]+$"},"bytesBilled":{"type":"string","pattern":"^[0-9]+$"},"cacheHit":{"type":"boolean"},"slotMs":{"type":"string","pattern":"^[0-9]+$"}}},"proofs":{"type":"array","description":"V2, una entrada por candidato incluyendo pendientes; el cliente debe cercar por candidateRevision.","items":{"type":"object","properties":{"userId":{"type":"string"},"candidateRevision":{"type":"string"},"proofScope":{"type":"string","enum":["user-document-derived"]},"sourceVersion":{"type":"integer","format":"int64"},"observedSourceVersion":{"type":"integer","format":"int64","nullable":true},"recordTs":{"type":"string","nullable":true,"description":"Epoch microseconds as an exact string"},"proofKind":{"type":"string","enum":["PROVEN","REMOVED","PENDING"]}}}},"consumed":{"type":"integer","description":"Entradas demostradas","example":998},"pending":{"type":"integer","description":"Entradas sin demostrar; el lote está listo cuando vale 0","example":2},"missing":{"type":"array","description":"Una entrada por prueba fallida (un usuario puede repetirse)","items":{"type":"object","properties":{"userId":{"type":"string","example":"68388013e2226d79536e4ae2"},"reason":{"type":"string","description":"`version`, `deleted`, `label`, `field:<clave>`,\n`field:<clave>:ambiguous`, `field:<clave>:unusable`\n(metadatos o `typed_value` nulos) o `field:<clave>:stale`\n(el origen borró el valor y el mart lo conserva).\nV2 añade `candidate_superseded`, `membership`,\n`membership:stale`, `consumer_version`, `custom_version`,\n`consumer_input:<clave>`, `source_input:<clave>`,\n`consumer_field:<clave>`, `label:<id>`,\n`label:<id>:stale` y `label:<id>:not_in_tenant_catalog`.\n","example":"field:cf:68c1d2e3f40718293a4b5c6d"}}}},"source":{"type":"string","enum":["legacy","resolved"],"description":"Origen de campos personalizados con el que se comprobó","example":"legacy"},"catalogVersion":{"type":"integer","nullable":true,"description":"Nueva versión de catálogo si se pidió y el lote estaba listo","example":null}}},"timestamp":{"type":"string","format":"date-time"}}}}}},"400":{"description":"Petición inválida: `jobId` o `chunkIndex` ausentes o mal formados, más de 1000\nentradas, `entries` vacío sin `bumpCatalogVersionOnReady`, una entrada mal formada,\no una clave de campo no soportada. El mensaje nombra siempre el campo o la clave.\nNo se ejecuta ninguna consulta.\n"},"401":{"description":"Token de servicio, x-service o x-tenant-id ausentes o inválidos"},"500":{"$ref":"#/components/responses/InternalServerError"},"503":{"description":"SERVICE_TOKEN no configurado, o presupuesto de consulta excedido.\nEn el segundo caso `errorCode=READINESS_BUDGET`: el reconciliador debe\ndetener las consultas automáticas y mostrar la necesidad de intervención.\n"}}}},"/import-sync/visibility":{"post":{"tags":["Import Sync"],"summary":"Comprobar si la entrada CDC de una importación ya es visible en el raw","description":"Endpoint servicio-a-servicio consumido por el reconciliador de importaciones de\nnev-api. **No acepta JWT de usuario.**\n\n`/import-sync/readiness` responde \"¿llegó la importación al MART?\", y esa pregunta\nsolo puede hacerse cuando una ejecución de Dataform ya ocurrió. Este endpoint responde\nla pregunta anterior y más barata: **¿está ya en BigQuery la entrada que esa ejecución\nleería?** El reconciliador solo despacha la ejecución cuando la respuesta le convence,\nde modo que no se paga una ejecución contra datos que aún no han llegado.\n\nSe consulta la tabla de merge de Datastream `<dataset raw>.users`, acotada por la\nlista de claves. Una entrada es **visible** cuando existe su fila y su\n`modifiedAt` (epoch ms; también se acepta la forma ISO, igual que `02_stg_users.sqlx`)\nes mayor o igual que `sourceVersion`. `labelVisible` es cierto cuando `importLabelId`\nes nulo, o cuando existe la fila de la etiqueta en `<dataset raw>.labels` y no tiene\n`deletedAt`.\n\nLa clave de almacenamiento no es el hex plano: es la clave de documento en JSON\nextendido de Mongo, y conviven dos codificaciones (`{\"$oid\": \"<hex>\"}` y\n`{\"$oid\":\"<hex>\"}`). Se prueban ambas por cada id.\n\nLa sonda **no filtra por tenant**: la fila raw es la imagen CDC previa a la\nresolución de tenant, y su única señal de tenant es `memberOf`, justo la escritura\ncuyo retraso esta sonda existe para detectar. El tenant se exige en la cabecera y se\nregistra en el log.\n\nEl dataset raw se toma de `BIGQUERY_RAW_DATASET`; si no está definido se deriva del\nsufijo del mart (`mart_nevent_prd` → `raw_prd_nevent`, `mart_nevent_dev` →\n`raw_dev_nevent`).\n","operationId":"checkImportSyncVisibility","security":[{"serviceAuth":[]}],"parameters":[{"name":"x-service","in":"header","required":true,"schema":{"type":"string","enum":["nev-api"]},"description":"Identifica qué servicio interno llama"},{"name":"x-tenant-id","in":"header","required":true,"schema":{"type":"string"},"description":"Tenant sobre el que se comprueba (nunca se lee del body)"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["jobId","entries"],"properties":{"jobId":{"type":"string","minLength":1,"description":"Job de importación al que pertenece la sonda. Obligatorio y no vacío: es\nlo que permite rastrear la respuesta en Cloud Logging.\n","example":"68a1b2c3d4e5f60718293a4b"},"importLabelId":{"type":"string","nullable":true,"description":"Etiqueta aplicada por la importación. Si viene, se comprueba además que su\nfila raw exista y no esté borrada. Si es nula, `labelVisible` es cierto y\nno se consulta la tabla de etiquetas.\n","example":"68b2c3d4e5f60718293a4b5c"},"entries":{"type":"array","minItems":1,"maxItems":1000,"description":"Claves aceptadas de la importación. No puede ir vacío: una sonda sin nada\nque demostrar se rechaza con 400, porque `visible: 0 de 0` se leería como\n\"todo visible\" en una regla de umbral.\n","items":{"type":"object","required":["userId","sourceVersion"],"properties":{"userId":{"type":"string","example":"68388013e2226d79536e4ae2"},"sourceVersion":{"type":"integer","format":"int64","description":"modifiedAt esperado en la imagen CDC, en epoch ms","example":1789034400000}}}}}}}}},"responses":{"200":{"description":"Sonda ejecutada","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","properties":{"total":{"type":"integer","description":"Entradas sondeadas","example":1000},"visible":{"type":"integer","description":"Entradas cuya fila raw ya alcanza la versión esperada","example":998},"labelVisible":{"type":"boolean","description":"Cierto si `importLabelId` es nulo, o si su fila raw existe y no está\nborrada\n","example":true},"missingSample":{"type":"array","maxItems":50,"description":"Hasta 50 ids todavía no visibles, para diagnóstico. La magnitud está\nen `total - visible`.\n","items":{"type":"string","example":"68388013e2226d79536e4ae2"}}}},"timestamp":{"type":"string","format":"date-time"}}}}}},"400":{"description":"Petición inválida: `jobId` ausente o vacío, `entries` que no es un array, vacío o\ncon más de 1000 entradas, una entrada sin `userId` o con `sourceVersion` no\nnumérico, o `importLabelId` que no es cadena ni nulo. El mensaje nombra siempre el\ncampo. No se ejecuta ninguna consulta.\n"},"401":{"description":"Token de servicio, x-service o x-tenant-id ausentes o inválidos"},"500":{"$ref":"#/components/responses/InternalServerError"},"503":{"description":"SERVICE_TOKEN no configurado en el servicio"}}}},"/fan-event-activity/{userId}":{"get":{"tags":["Fan Event Activity"],"summary":"Actividad por evento de un fan (histórico completo + página de filas)","description":"Lectura interna de la vista **Eventos** de la ficha del fan (ticket #59), consumida por la\nfachada CRM de nev-api. **Solo JWT de usuario** (`type: access_token`) con rol ADMIN, OWNER o\nSUPERADMIN; un token de servicio o STAFF/USER es 403. El alcance es el del JWT\n(tenant activo y descendientes autorizados); `x-tenant-id` se ignora.\n\n`history` cubre **toda** la actividad accesible del fan, sin filtros ni paginación. Cada\noperación cae en una sola fila (EDITION, EVENT_GROUP, EVENT, SOURCE o UNASSIGNED). Los\nimportes son céntimos EUR en cadenas decimales. Un importe desconocido nunca es 0: `status`\nes PARTIAL o UNKNOWN y `unknownCount` cuenta registros inciertos.\n`coverage.reasons` son códigos estables, sin ids. Un consumo cashless de importe desconocido\naporta a su fila y al histórico su `cashless_unknown_reason` (`RAW_RECLASSIFIED_PENDING`,\n`ANOMALY_FOREIGN_PROVIDER`, `ANOMALY_NO_CASFID_SHAPE`, `NON_CANONICAL_REFERENCE`,\n`METADATA_MISSING`, `IMAGE_FIELD_MISMATCH`, `METADATA_VERSION_MISMATCH`, `SOURCE_DEGRADED`,\n`SOURCE_STATE_UNRECOGNIZED`, `METADATA_CONTRADICTION`) o `CASHLESS_AMOUNT_UNKNOWN`.\n\nLa respuesta lleva `snapshotToken`: las páginas siguientes y todos los subrecursos lo exigen.\nSi la generación de datos o los vínculos cambian, la respuesta es `409 SNAPSHOT_CHANGED`.\n\n**Interruptor versionado** (`src/config/fanEventActivity.ts`): activo en producción y\ndesarrollo desde 3.38.1. Apagarlo es un PR; apagado, toda ruta responde `404 ACTIVITY_DISABLED`\nantes de autenticar.\n\n**Autoridad vigente** (S07): en cada petición, antes de leer datos, se relee sin caché el usuario\ndel token y su jerarquía. Si el usuario no existe o está borrado, si su rol ya no es el del\ntoken, o si el tenant efectivo ya no está en su tenant ni en sus descendientes, la respuesta es\n`401 AUTHORITY_CHANGED` (sin detalle), y se resuelve renovando la sesión. Un token de\nsuplantación con sesión viva tiene su propia autoridad.\n\nErrores: cuerpo `{code, message, requestId}`. Códigos: 400 INVALID_FILTER / INVALID_PAGE /\nINVALID_SNAPSHOT, 401 UNAUTHENTICATED / AUTHORITY_CHANGED, 403 FORBIDDEN, 404 FAN_NOT_FOUND /\nROW_NOT_FOUND / OPERATION_NOT_FOUND, 409 SNAPSHOT_CHANGED / SNAPSHOT_EXPIRED, 422\nDATA_INTEGRITY_UNRESOLVED, 429 RATE_LIMITED (con `Retry-After`), 503 ACTIVITY_UNAVAILABLE /\nTENANT_HIERARCHY_UNAVAILABLE (con `Retry-After`).\n","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string","pattern":"^[0-9a-fA-F]{24}$"}},{"name":"q","in":"query","schema":{"type":"string","maxLength":120},"description":"Búsqueda en título y canales visibles"},{"name":"kind","in":"query","schema":{"type":"string","enum":["EDITION","EVENT_GROUP","EVENT","SOURCE","UNASSIGNED"]}},{"name":"year","in":"query","schema":{"type":"integer"},"description":"Año de celebración"},{"name":"page","in":"query","schema":{"type":"integer","minimum":0,"default":0}},{"name":"size","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":25}},{"name":"snapshotToken","in":"query","schema":{"type":"string"},"description":"Opcional en la primera página; obligatorio para continuar"}],"responses":{"200":{"description":"Contrato fan-event-activity-v1 (Overview)","content":{"application/json":{"example":{"contractVersion":"fan-event-activity-v1","snapshotToken":"opaque","generatedAt":"2026-09-28T09:00:00.000Z","freshness":{"status":"STALE","economicAsOf":"2026-09-28T08:45:00.000Z","ticketsAsOf":"2026-09-28T07:00:18.000Z","complementsAsOf":"2026-09-28T08:45:00.000Z","marketingAsOf":"2026-03-04T10:36:56.000Z","relationsAsOf":"2026-09-28T08:59:00.000Z"},"userId":59,"history":{"rowCount":2,"metrics":{"purchasedTickets":{"knownValue":"4","unknownCount":1,"status":"PARTIAL"},"invitations":{"knownValue":"0","unknownCount":0,"status":"KNOWN"},"registrations":{"knownValue":"0","unknownCount":0,"status":"KNOWN"},"complementUnits":{"knownValue":"2","unknownCount":0,"status":"KNOWN"},"ticketValue":{"knownValue":"25200","unknownCount":1,"status":"PARTIAL","currency":"EUR","basis":"TICKET_REPORTED","valueBases":["PROVIDER_REPORTED","PURCHASE_LINE_UNIT"]},"complementValue":{"knownValue":"0","unknownCount":2,"status":"UNKNOWN","currency":"EUR","basis":"COMPLEMENT_REPORTED"},"ticketingRegistered":{"knownValue":"31850","unknownCount":0,"status":"KNOWN","currency":"EUR","basis":"SETTLED_GROSS"},"cashlessConsumed":{"knownValue":"9650","unknownCount":0,"status":"KNOWN","currency":"EUR","basis":"CASHLESS_CONSUMPTION"},"registered":{"knownValue":"41500","unknownCount":0,"status":"KNOWN","currency":"EUR","basis":"REGISTERED_GROSS"}},"coverage":{"status":"PARTIAL","reasons":["NATURE_UNKNOWN","PRICE_MISSING"]}},"capabilities":{"cashless":"AVAILABLE","marketing":"AVAILABLE","manageEdition":false},"appliedFilters":{"q":null,"kind":null,"year":null},"page":{"content":[],"number":0,"size":25,"totalElements":2,"totalPages":1,"hasNext":false}}}}},"400":{"description":"INVALID_FILTER, INVALID_PAGE, INVALID_SNAPSHOT"},"401":{"description":"UNAUTHENTICATED o AUTHORITY_CHANGED (el token ya no representa la autoridad vigente)"},"403":{"description":"FORBIDDEN"},"404":{"description":"FAN_NOT_FOUND (inexistente o fuera del alcance","indistinguibles)":null},"409":{"description":"SNAPSHOT_CHANGED, SNAPSHOT_EXPIRED"},"429":{"description":"RATE_LIMITED","con cabecera Retry-After":null},"503":{"description":"ACTIVITY_UNAVAILABLE"}}}},"/fan-event-activity/{userId}/rows/{rowKey}":{"get":{"tags":["Fan Event Activity"],"summary":"Detalle de fila con miembros visibles (paginados)","description":"Mismas reglas de autenticación, alcance y errores que el overview. `rowKey` es opaca, ligada al usuario, al fan y al alcance que la recibieron; una clave ajena es 404. `snapshotToken` es obligatorio.","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}},{"name":"rowKey","in":"path","required":true,"schema":{"type":"string","pattern":"^[A-Za-z0-9_-]+$","maxLength":256}},{"name":"snapshotToken","in":"query","required":true,"schema":{"type":"string"}},{"name":"page","in":"query","schema":{"type":"integer","minimum":0,"default":0}},{"name":"size","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":25}}],"responses":{"200":{"description":"Contrato fan-event-activity-v1 (RowDetail)"},"400":{"description":"INVALID_FILTER, INVALID_PAGE, INVALID_SNAPSHOT"},"404":{"description":"FAN_NOT_FOUND, ROW_NOT_FOUND, OPERATION_NOT_FOUND"},"409":{"description":"SNAPSHOT_CHANGED, SNAPSHOT_EXPIRED"},"503":{"description":"ACTIVITY_UNAVAILABLE"}}}},"/fan-event-activity/{userId}/rows/{rowKey}/members":{"get":{"tags":["Fan Event Activity"],"summary":"Miembros visibles de la fila, paginados","description":"Mismas reglas de autenticación, alcance y errores que el overview. `rowKey` es opaca, ligada al usuario, al fan y al alcance que la recibieron; una clave ajena es 404. `snapshotToken` es obligatorio.","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}},{"name":"rowKey","in":"path","required":true,"schema":{"type":"string","pattern":"^[A-Za-z0-9_-]+$","maxLength":256}},{"name":"snapshotToken","in":"query","required":true,"schema":{"type":"string"}},{"name":"page","in":"query","schema":{"type":"integer","minimum":0,"default":0}},{"name":"size","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":25}}],"responses":{"200":{"description":"Contrato fan-event-activity-v1 (Members)"},"400":{"description":"INVALID_FILTER, INVALID_PAGE, INVALID_SNAPSHOT"},"404":{"description":"FAN_NOT_FOUND, ROW_NOT_FOUND, OPERATION_NOT_FOUND"},"409":{"description":"SNAPSHOT_CHANGED, SNAPSHOT_EXPIRED"},"503":{"description":"ACTIVITY_UNAVAILABLE"}}}},"/fan-event-activity/{userId}/rows/{rowKey}/orders":{"get":{"tags":["Fan Event Activity"],"summary":"Pedidos de la fila con su primera página de líneas","description":"Mismas reglas de autenticación, alcance y errores que el overview. `rowKey` es opaca, ligada al usuario, al fan y al alcance que la recibieron; una clave ajena es 404. `snapshotToken` es obligatorio.","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}},{"name":"rowKey","in":"path","required":true,"schema":{"type":"string","pattern":"^[A-Za-z0-9_-]+$","maxLength":256}},{"name":"snapshotToken","in":"query","required":true,"schema":{"type":"string"}},{"name":"page","in":"query","schema":{"type":"integer","minimum":0,"default":0}},{"name":"size","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":25}}],"responses":{"200":{"description":"Contrato fan-event-activity-v1 (Orders)"},"400":{"description":"INVALID_FILTER, INVALID_PAGE, INVALID_SNAPSHOT"},"404":{"description":"FAN_NOT_FOUND, ROW_NOT_FOUND, OPERATION_NOT_FOUND"},"409":{"description":"SNAPSHOT_CHANGED, SNAPSHOT_EXPIRED"},"503":{"description":"ACTIVITY_UNAVAILABLE"}}}},"/fan-event-activity/{userId}/rows/{rowKey}/orders/{purchaseId}/lines":{"get":{"tags":["Fan Event Activity"],"summary":"Líneas (tickets y productos) de un pedido de la fila","description":"Mismas reglas de autenticación, alcance y errores que el overview. `rowKey` es opaca, ligada al usuario, al fan y al alcance que la recibieron; una clave ajena es 404. `snapshotToken` es obligatorio.","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}},{"name":"rowKey","in":"path","required":true,"schema":{"type":"string","pattern":"^[A-Za-z0-9_-]+$","maxLength":256}},{"name":"purchaseId","in":"path","required":true,"schema":{"type":"string"}},{"name":"snapshotToken","in":"query","required":true,"schema":{"type":"string"}},{"name":"page","in":"query","schema":{"type":"integer","minimum":0,"default":0}},{"name":"size","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":25}}],"responses":{"200":{"description":"Contrato fan-event-activity-v1 (OrderLines)"},"400":{"description":"INVALID_FILTER, INVALID_PAGE, INVALID_SNAPSHOT"},"404":{"description":"FAN_NOT_FOUND, ROW_NOT_FOUND, OPERATION_NOT_FOUND"},"409":{"description":"SNAPSHOT_CHANGED, SNAPSHOT_EXPIRED"},"503":{"description":"ACTIVITY_UNAVAILABLE"}}}},"/fan-event-activity/{userId}/rows/{rowKey}/cashless":{"get":{"tags":["Fan Event Activity"],"summary":"Operaciones cashless de la fila por sección (consumption, topups, promotions)","description":"Mismas reglas de autenticación, alcance y errores que el overview. `rowKey` es opaca, ligada al usuario, al fan y al alcance que la recibieron; una clave ajena es 404. `snapshotToken` es obligatorio.","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}},{"name":"rowKey","in":"path","required":true,"schema":{"type":"string","pattern":"^[A-Za-z0-9_-]+$","maxLength":256}},{"name":"section","in":"query","schema":{"type":"string","enum":["consumption","topups","promotions"],"default":"consumption"}},{"name":"snapshotToken","in":"query","required":true,"schema":{"type":"string"}},{"name":"page","in":"query","schema":{"type":"integer","minimum":0,"default":0}},{"name":"size","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":25}}],"responses":{"200":{"description":"Contrato fan-event-activity-v1 (Cashless)"},"400":{"description":"INVALID_FILTER, INVALID_PAGE, INVALID_SNAPSHOT"},"404":{"description":"FAN_NOT_FOUND, ROW_NOT_FOUND, OPERATION_NOT_FOUND"},"409":{"description":"SNAPSHOT_CHANGED, SNAPSHOT_EXPIRED"},"503":{"description":"ACTIVITY_UNAVAILABLE"}}}},"/fan-event-activity/{userId}/rows/{rowKey}/cashless/{operationKey}/lines":{"get":{"tags":["Fan Event Activity"],"summary":"Desglose de un consumo cashless de la fila","description":"Mismas reglas de autenticación, alcance y errores que el overview. `rowKey` es opaca, ligada al usuario, al fan y al alcance que la recibieron; una clave ajena es 404. `snapshotToken` es obligatorio.","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}},{"name":"rowKey","in":"path","required":true,"schema":{"type":"string","pattern":"^[A-Za-z0-9_-]+$","maxLength":256}},{"name":"operationKey","in":"path","required":true,"schema":{"type":"string","pattern":"^[A-Za-z0-9_-]+$","maxLength":256},"description":"Clave opaca emitida por esta API"},{"name":"snapshotToken","in":"query","required":true,"schema":{"type":"string"}},{"name":"page","in":"query","schema":{"type":"integer","minimum":0,"default":0}},{"name":"size","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":25}}],"responses":{"200":{"description":"Contrato fan-event-activity-v1 (CashlessLines)"},"400":{"description":"INVALID_FILTER, INVALID_PAGE, INVALID_SNAPSHOT"},"404":{"description":"FAN_NOT_FOUND, ROW_NOT_FOUND, OPERATION_NOT_FOUND"},"409":{"description":"SNAPSHOT_CHANGED, SNAPSHOT_EXPIRED"},"503":{"description":"ACTIVITY_UNAVAILABLE"}}}},"/fan-event-activity/{userId}/rows/{rowKey}/marketing":{"get":{"tags":["Fan Event Activity"],"summary":"Campañas vinculadas por Campaign.eventId y actividad de email del fan","description":"Mismas reglas de autenticación, alcance y errores que el overview. `rowKey` es opaca, ligada al usuario, al fan y al alcance que la recibieron; una clave ajena es 404. `snapshotToken` es obligatorio.","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string"}},{"name":"rowKey","in":"path","required":true,"schema":{"type":"string","pattern":"^[A-Za-z0-9_-]+$","maxLength":256}},{"name":"snapshotToken","in":"query","required":true,"schema":{"type":"string"}},{"name":"page","in":"query","schema":{"type":"integer","minimum":0,"default":0}},{"name":"size","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":25}}],"responses":{"200":{"description":"Contrato fan-event-activity-v1 (Marketing)"},"400":{"description":"INVALID_FILTER, INVALID_PAGE, INVALID_SNAPSHOT"},"404":{"description":"FAN_NOT_FOUND, ROW_NOT_FOUND, OPERATION_NOT_FOUND"},"409":{"description":"SNAPSHOT_CHANGED, SNAPSHOT_EXPIRED"},"503":{"description":"ACTIVITY_UNAVAILABLE"}}}},"/events/{eventId}/overview":{"get":{"tags":["Event Sales Provenance"],"summary":"Totales del evento, cobertura y frescura por bloque (#22)","description":"Cabecera única de la vista de ventas del evento. `eventTotals` sale de la misma tabla,\npoblación, fechas y moneda que `sales-provenance/summary`: con iguales filtros, las dos\ncifras son idénticas. Importes: **bruto informado por el origen**, en céntimos de su\nmoneda; el neto no se calcula (`netAmountStatus: NOT_PROVIDED`). Las monedas nunca se\nsuman. Cada bloque declara su naturaleza (`MEASURED`, `PROVIDER_REPORTED`,\n`NEVENT_CAPTURED`, `MIXED`, `NONE`) y la frescura no es completitud.\n\n`freshness.warehouseBuiltAt` es la construcción del almacén de la que dependen las cifras. `providerSync.FEVER`\n(solo en eventos de Fever) es el estado de la sincronización con el proveedor, leído en cada petición y separado\ndel almacén. Se resuelve el ámbito antes que la cardinalidad y falla cerrado:\n- `UNAVAILABLE` (`SYNC_SOURCE_OUT_OF_SCOPE`, `SYNC_SOURCE_AMBIGUOUS`, `SYNC_SOURCE_UNKNOWN`, `SYNC_SOURCE_UNREGISTERED` o\n  `SYNC_EVIDENCE_MISSING`) no lleva fechas ni contadores. Solo fechan las ejecuciones COMPLETED con\n  evidencia `fever-sync-evidence-v1`, `searchComplete` y `lossless`.\n- `LOAD_ERROR` (`SYNC_STATE_READ_ERROR`) indica que MongoDB no respondió.\n- Con `AVAILABLE`, `requestedUntil` es «pedido hasta» (nunca «completo»), `completeness` vale siempre `false` y\n  `pendingRetriesKnownForEvent` es un mínimo.\n","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/SalesProvenanceEventId"},{"$ref":"#/components/parameters/SalesProvenanceFrom"},{"$ref":"#/components/parameters/SalesProvenanceTo"},{"$ref":"#/components/parameters/SalesProvenanceTz"},{"$ref":"#/components/parameters/SalesProvenanceCurrency"},{"$ref":"#/components/parameters/SalesProvenancePopulation"}],"responses":{"200":{"description":"Resumen del evento","content":{"application/json":{"example":{"success":true,"message":"Resumen del evento obtenido exitosamente","data":{"eventId":"0123456789abcdef01234567","filters":{"from":"2026-09-01","to":"2026-09-30","tz":"Europe/Madrid","utcFrom":"2026-08-31T22:00:00.000Z","utcTo":"2026-09-30T22:00:00.000Z","population":"SETTLED_SALES","settlementStates":["SETTLED"]},"byCurrency":[{"currency":"EUR","eventTotals":{"orders":372,"ticketsSold":646,"ticketsTransferred":0,"ticketsCancelled":3,"reportedGrossCents":1860000,"grossUnknownOrders":0,"partialCancellationOrders":2,"netAmountStatus":"NOT_PROVIDED","settlement":{"settled":372,"refundedFull":0,"refundedUnrepresentable":0,"unrecognized":0}},"coverage":{"denominatorOrders":372,"fields":{"utmSource":{"PRESENT":95,"EMPTY":277,"UNKNOWN":0,"NOT_RECEIVED":0,"NOT_CAPTURED":0,"NOT_APPLICABLE":0,"NOT_SUPPORTED":0,"UNKNOWN_CAPABILITY":0}}},"providers":[{"providerKey":"FEVER","providerName":"FEVER"}]}],"excludedNoPurchaseDate":0,"blocks":{"sales":{"nature":"MEASURED","source":"mart.purchase_sales_provenance","status":"AVAILABLE","denominator":"ORDERS_OF_POPULATION"},"promoCode":{"nature":"PROVIDER_REPORTED","originOrders":{"providerReported":41,"neventCaptured":0},"source":"mart.purchase_sales_provenance","status":"AVAILABLE","denominator":"ORDERS_OF_POPULATION"},"marketing":{"nature":"PROVIDER_REPORTED","originOrders":{"providerReported":372,"neventCaptured":0},"source":"mart.purchase_sales_provenance","status":"AVAILABLE","denominator":"ORDERS_OF_POPULATION"}},"freshness":{"status":"CURRENT","warehouseBuiltAt":"2026-09-28T10:45:03.000Z","builtAt":"2026-09-28T11:00:04.000Z","factsBuiltAt":"2026-09-28T10:45:03.000Z","dataBuiltAt":"2026-09-28T10:45:03.000Z","staleAfterMinutes":135,"asOfIsCompleteness":false},"rulesVersion":"sp-2026-09-28.2","providerSync":{"FEVER":{"status":"AVAILABLE","reason":null,"resolution":"RECORDED","lastFinishedAt":"2026-09-28T10:05:00.000Z","requestedUntil":"2026-09-28T10:00:00.000Z","evidenceVersion":"fever-sync-evidence-v1","completeness":false,"lastAttempt":{"outcome":"COMPLETED","at":"2026-09-28T10:05:00.000Z","runningSince":null,"stale":false},"cadenceMinutes":60,"pendingRetriesKnownForEvent":0}}}}}}},"400":{"$ref":"#/components/responses/SalesProvenanceBadRequest"},"401":{"description":"Token ausente o inválido"},"403":{"description":"Rol sin acceso (ADMIN","OWNER o SUPERADMIN)":null},"404":{"$ref":"#/components/responses/SalesProvenanceNotFound"},"500":{"$ref":"#/components/responses/SalesProvenanceServerError"}}}},"/events/{eventId}/sales-provenance/summary":{"get":{"tags":["Event Sales Provenance"],"summary":"Distribución y origen de marketing de las ventas del evento (#22)","description":"Desglose por `dimension`: `provider` (ticketera), `distribution` (canal del proveedor),\n`purchaseChannel`, `promoCode` (código promocional del pedido: etiqueta, no causalidad)\no `marketing` (fuente → medio → campaña, con `level` y `parent`). Claves de proveedor:\n`FEVER`, `DICE`, `NEVENT` o `PROVIDER:<id>`. En DICE, canal de distribución y UTM son\n`NOT_SUPPORTED` (su API no los da por pedido).\nBloques por moneda y, dentro, por proveedor: un slug igual en dos proveedores son dos\nfilas distintas. Cada bloque cumple **filas + otros + sin información = total del\nproveedor** (o = `parentTotals` en niveles medio/campaña); si no, 500\n`INVARIANT_VIOLATION`. Ranking: pedidos DESC, bruto DESC, clave ASC; `top` filas y el\nresto en «Otros». `search` solo selecciona filas en `searchMatches` (incluidas las que\nestán dentro de «Otros»); no cambia totales, otros ni cuotas. La etiqueta es el valor raw\nmás frecuente (desempate lexicográfico), con todas sus variantes en `rawVariants`. En\nmarketing, `origin` distingue lo informado por la ticketera (`PROVIDER_PROVENANCE`) de lo\ncapturado por Nevent (`NEVENT_ATTRIBUTION`). No se infiere «directo» desde null ni\n«paid social» desde «facebook». `ticketsTransferred` es diagnóstico y no se suma.\n","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/SalesProvenanceEventId"},{"$ref":"#/components/parameters/SalesProvenanceFrom"},{"$ref":"#/components/parameters/SalesProvenanceTo"},{"$ref":"#/components/parameters/SalesProvenanceTz"},{"$ref":"#/components/parameters/SalesProvenanceCurrency"},{"$ref":"#/components/parameters/SalesProvenancePopulation"},{"$ref":"#/components/parameters/SalesProvenanceDimension"},{"$ref":"#/components/parameters/SalesProvenanceLevel"},{"$ref":"#/components/parameters/SalesProvenanceParent"},{"$ref":"#/components/parameters/SalesProvenanceTop"},{"in":"query","name":"search","schema":{"type":"string","maxLength":100},"description":"Texto a buscar en etiqueta, valor normalizado y variantes raw (NFKC, minúsculas)"}],"responses":{"200":{"description":"Desglose conciliado","content":{"application/json":{"example":{"success":true,"data":{"eventId":"0123456789abcdef01234567","dimension":"marketing","level":"source","parentKey":null,"top":20,"search":null,"byCurrency":[{"currency":"EUR","shareBase":"EVENT","eventTotals":{"orders":372,"ticketsSold":646,"ticketsTransferred":0,"ticketsCancelled":3,"reportedGrossCents":1860000,"grossUnknownOrders":0,"partialCancellationOrders":2,"netAmountStatus":"NOT_PROVIDED","settlement":{"settled":372,"refundedFull":0,"refundedUnrepresentable":0,"unrecognized":0}},"parentTotals":null,"diagnostics":{"duplicatedImageOrders":0,"tenantMismatchOrders":0},"providers":[{"providerKey":"FEVER","providerName":"FEVER","rows":[{"key":"WyJtYXJrZXRpbmciLCJGRVZFUiIsIlBST1ZJREVSX1BST1ZFTkFOQ0UiLCJpbnN0YWdyYW0iXQ","label":"Instagram","labelSource":"RAW_MOST_FREQUENT","normalized":"instagram","origin":"PROVIDER_PROVENANCE","rawVariants":[{"raw":"Instagram","orders":40},{"raw":"instagram ","orders":3}],"rank":1,"orders":43,"ticketsSold":71,"reportedGrossCents":215000,"shareOrders":0.1156,"shareGross":0.1156}],"others":{"key":"__others__:FEVER","groups":12,"orders":52,"reportedGrossCents":260000},"unknown":{"items":[{"key":"WyJtYXJrZXRpbmciLCJGRVZFUiIsIlBST1ZJREVSX1BST1ZFTkFOQ0UiLCIjRU1QVFkiXQ","status":"EMPTY","origin":"PROVIDER_PROVENANCE","orders":277}]},"searchMatches":[]}]}]}}}}},"400":{"$ref":"#/components/responses/SalesProvenanceBadRequest"},"401":{"description":"Token ausente o inválido"},"403":{"description":"Rol sin acceso (ADMIN","OWNER o SUPERADMIN)":null},"404":{"$ref":"#/components/responses/SalesProvenanceNotFound"},"500":{"$ref":"#/components/responses/SalesProvenanceServerError"}}}},"/events/{eventId}/sales-provenance/daily":{"get":{"tags":["Event Sales Provenance"],"summary":"Serie diaria de ventas del evento apilada por una dimensión de distribución (#22)","description":"Mismos filtros, población, ámbito y totales que `summary`. `days` es densa: un elemento por\ndía local de `tz` entre `from` y `to`, también los días sin ventas (a cero). Cada día lleva\nlas siete cifras de `Measures` y, en `values`, solo las entradas de la leyenda con pedidos\nese día, en orden de leyenda.\nLeyenda (`values` del bloque de moneda): los `top` grupos `PRESENT` con el ranking de\n`summary` sobre todos los proveedores del bloque (`kind: GROUP`, misma `key` que la fila de\n`summary` con esa dimensión, válida para `purchases`), luego «Otros» (`__daily_others__`) y\nlos grupos sin información de cualquier estado (`__daily_unknown__`); estas dos claves no\nson de drill-down. Las monedas nunca se suman.\nInvariantes por moneda: Σ días = Σ leyenda = `eventTotals` (la misma consulta de totales\nque `summary`) y, en cada día, Σ celdas = cifras del día. Si no, 500 `INVARIANT_VIOLATION`\nsin cifras parciales.\n","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/SalesProvenanceEventId"},{"$ref":"#/components/parameters/SalesProvenanceFrom"},{"$ref":"#/components/parameters/SalesProvenanceTo"},{"$ref":"#/components/parameters/SalesProvenanceTz"},{"$ref":"#/components/parameters/SalesProvenanceCurrency"},{"$ref":"#/components/parameters/SalesProvenancePopulation"},{"in":"query","name":"dimension","schema":{"type":"string","enum":["provider","distribution","purchaseChannel","promoCode"],"default":"distribution"},"description":"Dimensión de la pila. marketing, level, parent y search no aplican (400)"},{"in":"query","name":"top","schema":{"type":"integer","minimum":1,"maximum":10,"default":4}}],"responses":{"200":{"description":"Serie diaria conciliada","content":{"application/json":{"example":{"success":true,"data":{"eventId":"0123456789abcdef01234567","dimension":"distribution","top":4,"filters":{"from":"2026-05-01","to":"2026-05-02","tz":"Europe/Madrid","utcFrom":"2026-04-30T22:00:00.000Z","utcTo":"2026-05-02T22:00:00.000Z","population":"SETTLED_SALES","settlementStates":["SETTLED"]},"byCurrency":[{"currency":"EUR","eventTotals":{"orders":61,"ticketsSold":98,"ticketsTransferred":0,"ticketsCancelled":0,"reportedGrossCents":377300,"grossUnknownOrders":0,"partialCancellationOrders":0,"netAmountStatus":"NOT_PROVIDED","settlement":{"settled":61,"refundedFull":0,"refundedUnrepresentable":0,"unrecognized":0}},"values":[{"key":"WyJkaXN0cmlidXRpb24iLCJGRVZFUiIsImZldmVyLW1hcmtldHBsYWNlIl0","kind":"GROUP","label":"fever-marketplace","normalized":"fever-marketplace","providerKey":"FEVER","providerName":"FEVER","rank":1,"groups":1,"orders":47,"ticketsSold":84,"ticketsTransferred":0,"ticketsCancelled":0,"reportedGrossCents":377300,"grossUnknownOrders":0,"partialCancellationOrders":0},{"key":"WyJkaXN0cmlidXRpb24iLCJGRVZFUiIsImludml0YXRpb24iXQ","kind":"GROUP","label":"invitation","normalized":"invitation","providerKey":"FEVER","providerName":"FEVER","rank":2,"groups":1,"orders":14,"ticketsSold":14,"ticketsTransferred":0,"ticketsCancelled":0,"reportedGrossCents":0,"grossUnknownOrders":0,"partialCancellationOrders":0}],"days":[{"date":"2026-05-01","orders":38,"ticketsSold":61,"ticketsTransferred":0,"ticketsCancelled":0,"reportedGrossCents":230100,"grossUnknownOrders":0,"partialCancellationOrders":0,"values":[{"key":"WyJkaXN0cmlidXRpb24iLCJGRVZFUiIsImZldmVyLW1hcmtldHBsYWNlIl0","orders":30,"ticketsSold":53,"reportedGrossCents":230100,"grossUnknownOrders":0},{"key":"WyJkaXN0cmlidXRpb24iLCJGRVZFUiIsImludml0YXRpb24iXQ","orders":8,"ticketsSold":8,"reportedGrossCents":0,"grossUnknownOrders":0}]},{"date":"2026-05-02","orders":23,"ticketsSold":37,"ticketsTransferred":0,"ticketsCancelled":0,"reportedGrossCents":147200,"grossUnknownOrders":0,"partialCancellationOrders":0,"values":[{"key":"WyJkaXN0cmlidXRpb24iLCJGRVZFUiIsImZldmVyLW1hcmtldHBsYWNlIl0","orders":17,"ticketsSold":31,"reportedGrossCents":147200,"grossUnknownOrders":0},{"key":"WyJkaXN0cmlidXRpb24iLCJGRVZFUiIsImludml0YXRpb24iXQ","orders":6,"ticketsSold":6,"reportedGrossCents":0,"grossUnknownOrders":0}]}]}],"excludedNoPurchaseDate":0,"freshness":{"status":"CURRENT","warehouseBuiltAt":"2026-10-01T09:05:00.000Z","builtAt":"2026-10-01T09:05:00.000Z","factsBuiltAt":"2026-10-01T09:02:00.000Z","dataBuiltAt":"2026-10-01T09:02:00.000Z","staleAfterMinutes":135,"asOfIsCompleteness":false},"rulesVersion":"sp-2026-09-28.2"},"message":"Serie diaria de ventas obtenida exitosamente"}}}},"400":{"$ref":"#/components/responses/SalesProvenanceBadRequest"},"401":{"description":"Token ausente o inválido"},"403":{"description":"Rol sin acceso (ADMIN","OWNER o SUPERADMIN)":null},"404":{"$ref":"#/components/responses/SalesProvenanceNotFound"},"500":{"$ref":"#/components/responses/SalesProvenanceServerError"}}}},"/events/{eventId}/sales-provenance/purchases":{"get":{"tags":["Event Sales Provenance"],"summary":"Compras de una fila, de «Otros» o de un grupo sin información (#22)","description":"Mismos filtros y ámbito que el summary. `key` es la clave de una fila o de un grupo\n`unknown.items[]`, o `__others__:<providerKey>` (exige `currency`, o `NONE`). «Otros»\nexcluye exactamente las `top` claves del ranking del summary. Orden total por fecha de\ncompra e id. Solo columnas de la lista blanca: sin datos personales, sin click IDs y sin\n`utm_content`/`utm_term`/dominio de referencia.\n","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/SalesProvenanceEventId"},{"$ref":"#/components/parameters/SalesProvenanceFrom"},{"$ref":"#/components/parameters/SalesProvenanceTo"},{"$ref":"#/components/parameters/SalesProvenanceTz"},{"$ref":"#/components/parameters/SalesProvenanceCurrency"},{"$ref":"#/components/parameters/SalesProvenancePopulation"},{"$ref":"#/components/parameters/SalesProvenanceDimension"},{"$ref":"#/components/parameters/SalesProvenanceLevel"},{"$ref":"#/components/parameters/SalesProvenanceParent"},{"$ref":"#/components/parameters/SalesProvenanceTop"},{"in":"query","name":"key","required":true,"schema":{"type":"string"}},{"in":"query","name":"page","schema":{"type":"integer","minimum":1,"default":1}},{"in":"query","name":"pageSize","schema":{"type":"integer","minimum":1,"maximum":100,"default":50}}],"responses":{"200":{"description":"Página de compras","content":{"application/json":{"example":{"success":true,"data":{"eventId":"0123456789abcdef01234567","key":"WyJtYXJrZXRpbmciLCJGRVZFUiIsIlBST1ZJREVSX1BST1ZFTkFOQ0UiLCJpbnN0YWdyYW0iXQ","page":1,"pageSize":50,"totalRows":43,"rows":[{"purchaseId":"66f0a1b2c3d4e5f601234567","providerPurchaseId":"98765432","purchasedAt":"2026-09-02T18:21:07.000Z","purchasedAtLocal":"2026-09-02 20:21:07","state":"SUCCEEDED","refundSignal":"NONE_OBSERVED","netAmountStatus":"NOT_PROVIDED","currency":"EUR","reportedGrossCents":5000,"ticketsSold":2,"ticketsTransferred":0,"ticketsCancelled":0,"providerKey":"FEVER","providerName":"FEVER","utmSourceRaw":"Instagram","utmSourceNormalized":"instagram","utmSourceStatus":"PRESENT"}]}}}}},"400":{"$ref":"#/components/responses/SalesProvenanceBadRequest"},"404":{"$ref":"#/components/responses/SalesProvenanceNotFound"},"500":{"$ref":"#/components/responses/SalesProvenanceServerError"}}}},"/events/{eventId}/sales-provenance/campaigns":{"get":{"tags":["Event Sales Provenance"],"summary":"Ventas por código de seguimiento y campaña vinculada (#22 B5,","description":"Lee el token `nv1` de `utm_campaign` (gramática compartida con nev-api\n`CampaignTrackingToken`) y lo resuelve contra los vínculos `CONFIRMED` de\n`campaign_event_links` del evento cuyo tenant propietario y tenant del evento están ambos en\nel ámbito del JWT. Una venta nunca se reparte ni se mueve de evento. Evento del token frente\nal `eventId` de la compra: `MATCH`, `MISMATCH` (token de otro evento: solo diagnóstico),\n`AMBIGUOUS` (tokens de dos eventos) y `NONE` (sin token); su suma es igual a\n`eventTotals`. Dentro de `MATCH`, la campaña: `RESOLVED` (exactamente un vínculo),\n`UNRESOLVED` (ningún vínculo en el ámbito), `AMBIGUOUS` (más de un vínculo o dos campañas\nen el token) y `EVENT_ONLY` (sin campaña identificada); su suma es igual a `event.MATCH`.\nSi alguna suma no cuadra: 500 `INVARIANT_VIOLATION`. `links.available=false` indica que la\ncolección de vínculos aún no existe en el entorno. 503 `CAMPAIGN_LINKS_UNAVAILABLE` si\nMongoDB no responde.\n","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/SalesProvenanceEventId"},{"$ref":"#/components/parameters/SalesProvenanceFrom"},{"$ref":"#/components/parameters/SalesProvenanceTo"},{"$ref":"#/components/parameters/SalesProvenanceTz"},{"$ref":"#/components/parameters/SalesProvenanceCurrency"},{"$ref":"#/components/parameters/SalesProvenancePopulation"}],"responses":{"200":{"description":"Ventas por clase y campaña","content":{"application/json":{"example":{"success":true,"data":{"eventId":"0123456789abcdef01234567","grammarVersion":"nv1","links":{"source":"campaign_event_links","available":true},"byCurrency":[{"currency":"EUR","event":{"MATCH":{"orders":72},"MISMATCH":{"orders":0},"AMBIGUOUS":{"orders":0},"NONE":{"orders":300}},"campaign":{"RESOLVED":{"orders":25},"UNRESOLVED":{"orders":5},"AMBIGUOUS":{"orders":2},"EVENT_ONLY":{"orders":40}},"resolved":[{"linkId":"66f0a1b2c3d4e5f601234567","provider":"META","accountId":"act_123","externalCampaignId":"120210000000001","campaignName":"Otoño","origin":"PROVIDER_PROVENANCE","orders":25,"ticketsSold":41,"reportedGrossCents":125000}],"ambiguous":[],"unresolved":[{"provider":"GOOGLE","externalCampaignId":"333","matchingLinks":0,"origin":"PROVIDER_PROVENANCE","orders":5}]}]}}}}},"400":{"$ref":"#/components/responses/SalesProvenanceBadRequest"},"404":{"$ref":"#/components/responses/SalesProvenanceNotFound"},"500":{"$ref":"#/components/responses/SalesProvenanceServerError"},"503":{"description":"CAMPAIGN_LINKS_UNAVAILABLE: MongoDB no disponible para leer los vínculos"}}}},"/events/{eventId}/sales-provenance/export.csv":{"get":{"tags":["Event Sales Provenance"],"summary":"CSV del desglose o de las compras de una fila (#22)","description":"`mode=summary` (por defecto; mismos parámetros que el summary) o `mode=purchases` (mismos\nparámetros que el drill-down, sin paginar; si hay más de 50.000 compras, 413\n`EXPORT_TOO_LARGE`, sin truncar). UTF-8 con BOM, separador `;` (`separator=comma` para `,`).\nLa cabecera indica evento, fechas, zona, población, reglas, frescura y la leyenda de\nbruto/neto. Toda celda de texto que empiece por `= + - @`, TAB o CR se prefija con `'`\npara que la hoja de cálculo no la evalúe.\n","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/SalesProvenanceEventId"},{"in":"query","name":"mode","schema":{"type":"string","enum":["summary","purchases"],"default":"summary"}},{"in":"query","name":"separator","schema":{"type":"string","enum":["semicolon","comma"],"default":"semicolon"}}],"responses":{"200":{"description":"Fichero CSV","content":{"text/csv":{"schema":{"type":"string"}}}},"400":{"$ref":"#/components/responses/SalesProvenanceBadRequest"},"404":{"$ref":"#/components/responses/SalesProvenanceNotFound"},"413":{"description":"Demasiadas compras para exportar","content":{"application/json":{"example":{"success":false,"error":{"code":"EXPORT_TOO_LARGE","message":"La exportación tiene 61234 compras; el máximo es 50000. Acota el rango de fechas."}}}}},"500":{"$ref":"#/components/responses/SalesProvenanceServerError"}}}}},"components":{"parameters":{"SalesProvenanceEventId":{"in":"path","name":"eventId","required":true,"schema":{"type":"string","pattern":"^[0-9a-f]{24}$"},"description":"Evento; debe pertenecer al ámbito del JWT (si no, 404)"},"SalesProvenanceFrom":{"in":"query","name":"from","required":true,"schema":{"type":"string","format":"date"},"description":"Primer día (local de tz) de la fecha de compra, inclusivo"},"SalesProvenanceTo":{"in":"query","name":"to","required":true,"schema":{"type":"string","format":"date"},"description":"Último día (local de tz), inclusivo: el límite es el inicio del día siguiente, exclusivo"},"SalesProvenanceTz":{"in":"query","name":"tz","schema":{"type":"string","default":"Europe/Madrid"},"description":"Zona IANA. Si el cambio de hora salta la medianoche, el día empieza en su primer instante existente"},"SalesProvenanceCurrency":{"in":"query","name":"currency","schema":{"type":"string"},"description":"ISO 4217 (p. ej. EUR) o NONE para compras sin moneda. Sin él: un bloque por moneda, nunca sumadas"},"SalesProvenancePopulation":{"in":"query","name":"population","schema":{"type":"string","enum":["SETTLED_SALES","REFUNDED_FULL","REFUNDED_UNREPRESENTABLE","ALL_REPORTED"],"default":"SETTLED_SALES"},"description":"Siempre compras activas y nunca registros de audiencia. SETTLED_SALES = liquidadas; REFUNDED_FULL = Fever totalmente cancelada o DICE con todas sus entradas devueltas; REFUNDED_UNREPRESENTABLE = otro REFUNDED (importe devuelto desconocido); ALL_REPORTED = las anteriores más estados no reconocidos, con desglose por estado. En ALL_REPORTED el bruto incluye el bruto informado de las compras reembolsadas por completo: no es ingreso cobrado"},"SalesProvenanceDimension":{"in":"query","name":"dimension","schema":{"type":"string","enum":["provider","distribution","purchaseChannel","promoCode","marketing"],"default":"marketing"},"description":"promoCode = código promocional del pedido (promoCode de DICE, cupón de Fever, cupón nativo). Es una etiqueta del pedido, no demuestra causalidad."},"SalesProvenanceLevel":{"in":"query","name":"level","schema":{"type":"string","enum":["source","medium","campaign"],"default":"source"},"description":"Solo marketing"},"SalesProvenanceParent":{"in":"query","name":"parent","schema":{"type":"string"},"description":"Clave completa (proveedor + origen + ruta) del nivel superior; obligatoria en medium/campaign"},"SalesProvenanceTop":{"in":"query","name":"top","schema":{"type":"integer","minimum":1,"maximum":50,"default":20}}},"securitySchemes":{"serviceAuth":{"type":"http","scheme":"bearer","description":"Token estático compartido entre servicios internos (`SERVICE_TOKEN`), no un JWT de\nusuario: los JWT se rechazan explícitamente por forma. Requiere además las cabeceras\n`x-service: nev-api` y `x-tenant-id`.\n\nEl servicio falla cerrado: si `SERVICE_TOKEN` no está configurado responde 503 en\nlugar de aceptar cualquier token.\n"},"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"JWT HS256 emitido por nev-api (cabecera `Authorization: Bearer …` o cookie\n`access_token` con `X-Auth-Mode: cookie`). Debe incluir `tenantId` y un `type`\nadmitido: `access_token` (usuarios del backoffice) o `campaign_service_token`\n(procesos de campañas y segmentos de nev-api). Cualquier otro tipo, como los tokens\nanónimos de chatbot o widget, o un token sin `type`, se rechaza con 401\n`INVALID_TOKEN_TYPE`.\n\n**Identidad y ámbito de datos (#62).** `tenantId` es la identidad de origen y se usa\npara autorización, auditoría y rate limiting. Cuando un ADMIN, OWNER o STAFF cambia de\norganización en el backoffice (`POST /users/tenant` de nev-api), el token conserva su\n`tenantId` y añade `activeTenantId` con la organización seleccionada. La Data API\nconsulta entonces **solo la organización seleccionada y sus descendientes**, después de\ncomprobar con una lectura actual de la jerarquía que la selección pertenece al árbol\nde `tenantId`.\n\n- `activeTenantId` ausente, `null` o `\"\"`: sin selección, ámbito del `tenantId`.\n- SUPERADMIN, USER, EXTERNAL y tokens sin rol: `activeTenantId` se ignora (el cambio\n  de SUPERADMIN ya emite otro `tenantId`).\n- `activeTenantId` que no es un ObjectId (array, objeto, espacios…): 401\n  `INVALID_ACTIVE_TENANT`.\n- `activeTenantId` fuera de la jerarquía de `tenantId`: 403 `ACTIVE_TENANT_FORBIDDEN`.\n- Jerarquía no disponible (MongoDB): 503 `TENANT_HIERARCHY_UNAVAILABLE`. Nunca se\n  responde con un ámbito degradado.\n- Token de impersonación (`impersonationSessionId`) cuya sesión ya no está activa o ha\n  expirado: 401 `IMPERSONATION_SESSION_TERMINATED`, igual que en nev-api.\n\nLas cabeceras (`x-tenant-id`, `X-App-Id`), la query string y el body (`tenantId`,\n`tenantIds`, `tenant_ids`) **no seleccionan ni amplían** el ámbito. Solo\ncampaign-report y user-interactions conservan la sobrescritura por `x-tenant-id`\nreservada a SUPERADMIN.\n\nEjemplo de payload de un OWNER del grupo que navega a una marca:\n```json\n{\n  \"iss\": \"https://nevent.es\",\n  \"sub\": \"5f62a00000000000000000aa\",\n  \"role\": \"OWNER\",\n  \"tenantId\": \"5f62a0000000000000000001\",\n  \"activeTenantId\": \"5f62a0000000000000000002\",\n  \"type\": \"access_token\",\n  \"iat\": 1758991399,\n  \"exp\": 1759596199\n}\n```\n"}},"schemas":{"DataType":{"type":"string","enum":["TEXT","NUMBER","BOOLEAN","DATE","ENTITY","CURRENCY"],"description":"Tipos de datos soportados por los criterios:\n\n- **TEXT**: Cadenas de texto\n- **NUMBER**: Valores numéricos\n- **BOOLEAN**: Verdadero/Falso\n- **DATE**: Fechas (ISO 8601)\n- **ENTITY**: Referencias a entidades (retorna {id, name})\n- **CURRENCY**: Valores monetarios (comportamiento de NUMBER)\n"},"SegmentationOperator":{"type":"string","enum":["eq","neq","gt","gte","lt","lte","contains","not_contains","starts_with","ends_with","is_true","is_false","before","after","between","is","is_not","is_set","is_not_set"],"description":"Operadores disponibles según tipo de dato:\n\n**TEXT**: eq, neq, contains, not_contains, starts_with, ends_with, is_set, is_not_set\n\n**NUMBER**: eq, neq, gt, gte, lt, lte, is_set, is_not_set\n\n**BOOLEAN**: is_true, is_false\n\n**DATE**: before, after, between, eq, is_set, is_not_set\n\n**ENTITY**: is, is_not, is_set, is_not_set\n\n**CURRENCY**: eq, neq, gt, gte, lt, lte, is_set, is_not_set\n"},"CategoryMetadata":{"type":"object","required":["id","name","description","icon","order"],"properties":{"id":{"type":"string","description":"ID único de la categoría","example":"asistencia"},"name":{"type":"string","description":"Nombre de la categoría","example":"Asistencia"},"description":{"type":"string","description":"Descripción de la categoría","example":"Eventos asistidos, comprados y experiencias vividas"},"icon":{"type":"string","description":"Icono para UI","example":"calendar"},"order":{"type":"integer","description":"Orden de visualización","example":1}}},"OperatorInfo":{"type":"object","required":["id","name","description","requiresValue"],"properties":{"id":{"type":"string","description":"ID del operador","example":"eq"},"name":{"type":"string","description":"Nombre legible","example":"Igual a"},"description":{"type":"string","description":"Descripción del operador","example":"El valor es exactamente igual"},"requiresValue":{"type":"boolean","description":"Si requiere un valor","example":true},"valueType":{"type":"string","enum":["single","array","range","none"],"description":"Tipo de valor esperado"},"placeholder":{"type":"string","description":"Placeholder para UI","example":"Ingrese un valor"}}},"SupportedModifiers":{"type":"object","properties":{"distance":{"type":"boolean","description":"Soporta modificador de distancia (radio geográfico)"},"frequency":{"type":"boolean","description":"Soporta modificador de frecuencia (número de veces)"},"time_range":{"type":"boolean","description":"Soporta modificador de rango temporal"}}},"CriterionInfo":{"type":"object","required":["id","name","description","type","data_type","operators","category"],"properties":{"id":{"type":"string","description":"ID único del criterio","example":"user_age"},"name":{"type":"string","description":"Nombre para mostrar","example":"Edad del usuario"},"description":{"type":"string","description":"Descripción del criterio","example":"Edad actual del usuario en años"},"type":{"type":"string","enum":["profile_property","behavior","communication_interaction","app_interaction","acquisition_source","predictive"],"description":"Tipo de criterio"},"data_type":{"$ref":"#/components/schemas/DataType"},"operators":{"type":"array","items":{"$ref":"#/components/schemas/OperatorInfo"},"description":"Operadores disponibles para este criterio"},"category":{"type":"string","description":"ID de la categoría","example":"atributos_fan"},"category_metadata":{"$ref":"#/components/schemas/CategoryMetadata"},"supported_modifiers":{"$ref":"#/components/schemas/SupportedModifiers"}}},"CriteriaResponse":{"type":"object","required":["success","criteria"],"properties":{"success":{"type":"boolean","example":true},"criteria":{"type":"array","items":{"$ref":"#/components/schemas/CriterionInfo"},"description":"Lista de criterios disponibles"}}},"EntityValue":{"type":"object","required":["id","name"],"properties":{"id":{"type":"string","description":"ID de la entidad","example":"evt_ps2024"},"name":{"type":"string","description":"Nombre de la entidad","example":"Primavera Sound 2024"},"description":{"type":"string","description":"Información adicional contextual (opcional). Para eventos muestra la fecha, para tipos de entrada muestra el evento.","example":"15 Jul 2024"}}},"DimensionValuesRequest":{"type":"object","required":["criterion_id"],"properties":{"criterion_id":{"type":"string","description":"ID del criterio","example":"attended_event"},"search_term":{"type":"string","description":"Filtro de búsqueda opcional con soporte completo para:\n- **Multi-término**: los espacios actúan como AND. `\"vip barakaldo\"` busca entidades\n  que contengan AMBOS términos (en cualquier orden y campo).\n- **Normalización de acentos**: `\"euforia\"` encuentra `\"Eufòria\"`, `\"malaga\"` encuentra\n  `\"Málaga\"`. Funciona en ambas direcciones.\n- **Búsqueda en description**: para criterios ENTITY con campo description configurado\n  (ej: `attended_event`, `ticket_type`), la búsqueda se extiende también a la descripción.\n- **Ranking por relevancia**: exact match primero, luego starts-with, luego contains.\n- **Case-insensitive**: `VIP` = `vip` = `Vip`.\n- **Retrocompatible**: si se omite, el comportamiento es idéntico a versiones anteriores.\n","example":"vip barakaldo"},"limit":{"type":"integer","description":"Límite de resultados por página","default":20,"minimum":1,"maximum":100},"offset":{"type":"integer","description":"**NEV-1714** — Desplazamiento cero-basado para paginación por offset.\n\n- Cuando está ausente o es 0: devuelve la primera página (comportamiento idéntico a v3.20).\n- Cuando se proporciona: omite los primeros `offset` resultados antes de aplicar `limit`.\n- **Restricciones**: debe ser un entero >= 0. Valores mayores a 10 000 son rechazados con HTTP 400.\n\n**Uso para cargar más resultados (infinite scroll):**\n```\n// Página 1\n{ \"criterion_id\": \"attended_event\", \"limit\": 20, \"offset\": 0 }\n// Página 2\n{ \"criterion_id\": \"attended_event\", \"limit\": 20, \"offset\": 20 }\n// Página 3\n{ \"criterion_id\": \"attended_event\", \"limit\": 20, \"offset\": 40 }\n```\n","default":0,"minimum":0,"maximum":10000,"example":20},"context":{"type":"object","description":"Contexto adicional (para custom fields)","properties":{"property_name":{"type":"string","description":"Nombre del custom field (solo caracteres alfanuméricos, guión, punto y espacio)","example":"preferred_music_genre"}}}}},"DimensionValuesResponse":{"type":"object","required":["success","values","has_more"],"properties":{"success":{"type":"boolean","example":true},"values":{"oneOf":[{"type":"array","items":{"type":"string"},"description":"Array de valores TEXT","example":["España","Francia","Portugal"]},{"type":"array","items":{"$ref":"#/components/schemas/EntityValue"},"description":"Array de valores ENTITY"}]},"has_more":{"type":"boolean","description":"**NEV-1714** — Indica si existen resultados adicionales más allá de la página actual.\n\n- `false`: los `values` devueltos son todos los resultados disponibles.\n- `true`: existen más resultados; envía la siguiente petición con `offset += limit`.\n\nSiempre presente en la respuesta (incluso cuando no se envió `offset`).\nCalculado con el truco LIMIT+1 para evitar un costoso COUNT(*).\n\nLos consumidores existentes que solo leen `values` no se ven afectados (cambio aditivo).\n","example":false}}},"TimeFrame":{"oneOf":[{"$ref":"#/components/schemas/TimeFrameRelativeToNow"},{"$ref":"#/components/schemas/TimeFrameRelativeToExperience"},{"$ref":"#/components/schemas/TimeFrameAbsolute"}],"description":"Marco temporal para el criterio"},"TimeFrameRelativeToNow":{"type":"object","required":["type","unit","value_start","value_end"],"properties":{"type":{"type":"string","enum":["relative_to_now"]},"unit":{"type":"string","enum":["days","hours","weeks","months"]},"value_start":{"type":"integer","description":"Inicio del rango (0 = ahora)"},"value_end":{"type":"integer","description":"Fin del rango"}}},"TimeFrameRelativeToExperience":{"type":"object","required":["type","experienceId","phase","unit","value"],"properties":{"type":{"type":"string","enum":["relative_to_experience"]},"experienceId":{"type":"string","description":"ID del evento de referencia"},"phase":{"type":"string","enum":["before_start","after_start","before_end","after_end"]},"unit":{"type":"string","enum":["days","hours","weeks"]},"value":{"type":"integer"}}},"TimeFrameAbsolute":{"type":"object","required":["type","start_date","end_date"],"properties":{"type":{"type":"string","enum":["absolute"]},"start_date":{"type":"string","format":"date","description":"Fecha de inicio (ISO 8601)"},"end_date":{"type":"string","format":"date","description":"Fecha de fin (ISO 8601)"}}},"DistanceModifier":{"type":"object","required":["radius"],"properties":{"radius":{"type":"number","description":"Radio en kilómetros","example":50},"unit":{"type":"string","enum":["km","mi"],"default":"km"}}},"FrequencyModifier":{"type":"object","required":["count","operator"],"properties":{"count":{"type":"integer","description":"Número de veces","example":3},"operator":{"type":"string","enum":["exactly","at_least","at_most"],"description":"Operador de comparación"}}},"TimeRangeModifier":{"type":"object","description":"Ventana relativa, recalculada en cada evaluación. En criterios cuya fecha está en\notra tabla (`attended_event` → `events.start_ts`) se evalúa sobre el mismo registro\nque cumple el criterio, en días naturales de Europe/Madrid y con ambos límites\nincluidos: `past` = desde las 00:00 de hoy − N unidades hasta ahora (sin eventos\nfuturos); `future` = desde ahora hasta el final de hoy + N unidades. Semanas = 7·N\ndías; meses y años, aritmética de calendario.\n","required":["value","unit"],"properties":{"value":{"type":"integer","description":"Valor numérico","example":30},"unit":{"type":"string","enum":["days","weeks","months","years"]},"direction":{"type":"string","enum":["past","future"],"default":"past"}}},"Modifiers":{"type":"object","properties":{"distance":{"$ref":"#/components/schemas/DistanceModifier"},"frequency":{"$ref":"#/components/schemas/FrequencyModifier"},"time_range":{"$ref":"#/components/schemas/TimeRangeModifier"}}},"Criterion":{"type":"object","required":["id","criterion_id","operator","value"],"properties":{"id":{"type":"string","description":"UUID para identificación en UI","example":"crit_001"},"type":{"type":"string","description":"Tipo de criterio (obsoleto, usar criterion_id)","deprecated":true},"criterion_id":{"type":"string","description":"ID del criterio global","example":"user_age"},"operator":{"$ref":"#/components/schemas/SegmentationOperator"},"value":{"description":"Valor del criterio (tipo depende del data_type)","oneOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"array"}]},"timeframe":{"$ref":"#/components/schemas/TimeFrame"},"filters":{"type":"object","description":"Filtros adicionales (ej. property_name para custom fields)","additionalProperties":true},"modifiers":{"$ref":"#/components/schemas/Modifiers"},"displayValue":{"description":"Valor para mostrar en UI"},"metadata":{"type":"object","description":"Metadata adicional para UI","properties":{"criteriaName":{"type":"string","example":"Campañas abiertas"},"dataType":{"type":"string","example":"ENTITY"},"category":{"type":"string","example":"Interacción"}},"additionalProperties":true}}},"Stanza":{"type":"object","required":["id","criteria"],"properties":{"id":{"type":"string","description":"UUID para identificación en UI","example":"stanza_001"},"criteria":{"type":"array","items":{"$ref":"#/components/schemas/Criterion"},"description":"Criterios unidos con OR","minItems":1}}},"Definition":{"type":"object","required":["stanzas"],"properties":{"stanzas":{"type":"array","items":{"$ref":"#/components/schemas/Stanza"},"description":"Stanzas unidas con AND","minItems":1},"limit":{"type":"integer","description":"Límite opcional de resultados (caso de uso warm-up de campañas).\nMáximo 100000. Si se especifica offset, limit es obligatorio.\n","minimum":1,"maximum":100000,"example":50},"offset":{"type":"integer","description":"Saltar N resultados antes de aplicar limit (skip+limit para warm-up).\nDebe ser >= 0. Requiere que limit también esté definido.\nOrden de aplicación: groups → offset/limit → page/page_size HTTP.\n","minimum":0,"example":0}}},"GroupsConfig":{"type":"object","required":["total_groups","group_number"],"properties":{"total_groups":{"type":"integer","minimum":1,"maximum":100,"description":"Número total de grupos en los que dividir el segmento","example":10},"group_number":{"type":"integer","minimum":1,"description":"Número del grupo a obtener (1-indexed, entre 1 y total_groups)","example":1}}},"GroupMetadata":{"type":"object","properties":{"total_groups":{"type":"integer","description":"Número total de grupos","example":10},"current_group":{"type":"integer","description":"Número del grupo actual (1-indexed)","example":1},"total_fans_all_groups":{"type":"integer","description":"Total de fans en el segmento completo (sin filtro de grupos)","example":52340},"avg_group_size":{"type":"integer","description":"Tamaño promedio esperado por grupo","example":5234},"assignment_method":{"type":"string","enum":["hash"],"description":"Método de asignación de fans a grupos","example":"hash"}}},"SegmentPreviewRequest":{"type":"object","required":["definition"],"properties":{"definition":{"$ref":"#/components/schemas/Definition"},"groups":{"$ref":"#/components/schemas/GroupsConfig"}}},"SegmentExecuteRequest":{"type":"object","required":["definition"],"properties":{"definition":{"$ref":"#/components/schemas/Definition"},"groups":{"$ref":"#/components/schemas/GroupsConfig"},"page_size":{"type":"integer","description":"Tamaño de página","default":100,"minimum":1,"maximum":1000},"page":{"type":"integer","description":"Número de página (0-indexed)","default":0,"minimum":0}}},"Fan":{"type":"object","required":["fan_id","first_name","last_name","email"],"properties":{"fan_id":{"type":"string","description":"ID del usuario","example":"usr_001"},"first_name":{"type":"string","description":"Nombre","example":"Juan"},"last_name":{"type":"string","description":"Apellido","example":"García"},"email":{"type":"string","format":"email","description":"Email","example":"juan.garcia@example.com"},"phone":{"type":"string","description":"Teléfono","example":"+34600123456"},"dni":{"type":"string","description":"DNI/Fiscal ID","example":"12345678A"},"email_marketing_status":{"type":"string","nullable":true,"enum":["ok","no_email","invalid_email","opt_out","unknown"],"description":"Estado de consentimiento de marketing por email del fan (NUEVO). Solo\npresente cuando la respuesta viene del path enriquecido de\ncontactabilidad (ver `contactable_counts` en `PreviewResponse`); ausente\npor completo en el path legacy (mart `user_tenants` sin las columnas de\ncontactabilidad todavía desplegadas).\n","example":"ok"}}},"ContactableCounts":{"type":"object","nullable":true,"description":"Conteo de fans contactables por canal dentro del segmento/audiencia, o\n`null` cuando el mart `user_tenants` todavía no expone las columnas de\ncontactabilidad (`contactable_email`, `contactable_sms`, `contactable_push`).\n","required":["email","sms","push"],"properties":{"email":{"type":"integer","example":612},"sms":{"type":"integer","example":430},"push":{"type":"integer","example":210}}},"EmailExclusions":{"type":"object","nullable":true,"description":"Desglose de por qué el resto del segmento NO es contactable por email, o\n`null` en las mismas condiciones que `contactable_counts`. Invariante:\n`estimated_fan_count = contactable_counts.email + email_exclusions.no_email\n+ email_exclusions.invalid_email + email_exclusions.opt_out + email_exclusions.unknown`.\n","required":["no_email","invalid_email","opt_out","unknown"],"properties":{"no_email":{"type":"integer","description":"Fans sin email registrado","example":120},"invalid_email":{"type":"integer","description":"Fans con email marcado como inválido","example":45},"opt_out":{"type":"integer","description":"Fans que se dieron de baja de comunicaciones comerciales","example":88},"unknown":{"type":"integer","description":"Fans sin fila en user_tenants para el tenant de consentimiento (fail-closed)","example":135}}},"PreviewResponse":{"type":"object","required":["success","estimated_fan_count","sample_fans","contactable_counts","email_exclusions","computed_at","data_as_of"],"properties":{"success":{"type":"boolean","example":true},"estimated_fan_count":{"type":"integer","description":"Estimación del total de fans","example":1247},"sample_fans":{"type":"array","items":{"$ref":"#/components/schemas/Fan"},"description":"Muestra de hasta 15 fans","maxItems":15},"group_metadata":{"$ref":"#/components/schemas/GroupMetadata","description":"Metadata de grupos (solo si se usa el parámetro groups)"},"contactable_counts":{"$ref":"#/components/schemas/ContactableCounts"},"email_exclusions":{"$ref":"#/components/schemas/EmailExclusions"},"computed_at":{"type":"string","format":"date-time","description":"Timestamp ISO de cuándo se calculó este preview (reloj del servicio)","example":"2026-07-17T09:15:32.000Z"},"data_as_of":{"type":"string","format":"date-time","nullable":true,"description":"Timestamp ISO de la frescura de los datos del mart subyacente, o `null`\ncuando la contactabilidad no se pudo calcular (ver `contactable_counts`).\n","example":"2026-07-16T03:00:00.000Z"},"economic_data_as_of":{"type":"string","format":"date-time","nullable":true,"description":"Solo si la definición usa un criterio económico (#57). Cambio de origen más reciente\nobservado a la vez en el dim de compras y en el raw (source_timestamp máximo): un máximo\nobservado, NO una marca de agua de completitud. No garantiza que estén reflejados todos\nlos cambios anteriores: una imagen que llega tarde o que el almacén retiene puede faltar\naunque el as-of sea posterior. Hoy no existe señal de completitud. Preséntese como «último\ncambio observado», nunca como «datos completos hasta». null si no se sabe. En un periodo\nsin cambios no avanza aunque el pipeline esté vivo.\n","example":"2026-09-23T09:58:12.000Z"},"economic_data_built_at":{"type":"string","format":"date-time","nullable":true,"description":"Solo con criterio económico. Construcción de purchase_economic_facts. Es vitalidad del pipeline; no es marca de agua de origen ni dice nada de completitud.","example":"2026-09-23T10:00:08.000Z"},"economic_data_status":{"type":"string","enum":["CURRENT","STALE","UNKNOWN"],"description":"Solo con criterio económico. Estado de VITALIDAD, no de completitud. CURRENT: facts se\nreconstruyó dentro de la cadencia (no certifica que estén todos los cambios de origen);\nSTALE si se han perdido dos o más ciclos de 15 min (el resultado se calculó igual, sobre la\núltima foto); UNKNOWN si la frescura no se pudo leer.\n","example":"CURRENT"},"edition_scopes":{"type":"array","description":"Solo con criterio por edición (#58: edition_spent, edition_participated). Edición y\nrevisión del vínculo usadas por cada criterio: solo miembros CONFIRMED a esa revisión.\npinned = la revisión venía en filters.edition_revision; si no, era la vigente.\n","items":{"type":"object","properties":{"edition_id":{"type":"string","example":"6ab4f2ea8f0f4cd41813aae8"},"revision":{"type":"integer","example":6},"pinned":{"type":"boolean","example":false}}}}}},"ExecuteResponse":{"type":"object","required":["success","total_fans","fans","current_page","total_pages","has_more"],"properties":{"success":{"type":"boolean","example":true},"total_fans":{"type":"integer","description":"Total de fans en el segmento","example":1247},"fans":{"type":"array","items":{"$ref":"#/components/schemas/Fan"},"description":"Fans de la página actual"},"current_page":{"type":"integer","description":"Página actual (0-indexed)","example":0},"total_pages":{"type":"integer","description":"Total de páginas","example":13},"has_more":{"type":"boolean","description":"Si hay más páginas disponibles","example":true},"group_metadata":{"$ref":"#/components/schemas/GroupMetadata","description":"Metadata de grupos (solo si se usa el parámetro groups)"},"economic_data_as_of":{"type":"string","format":"date-time","nullable":true,"description":"Solo si la definición usa un criterio económico (#57). Cambio de origen más reciente\nobservado a la vez en el dim de compras y en el raw (source_timestamp máximo): un máximo\nobservado, NO una marca de agua de completitud. No garantiza que estén reflejados todos\nlos cambios anteriores: una imagen que llega tarde o que el almacén retiene puede faltar\naunque el as-of sea posterior. Hoy no existe señal de completitud. Preséntese como «último\ncambio observado», nunca como «datos completos hasta». null si no se sabe. En un periodo\nsin cambios no avanza aunque el pipeline esté vivo.\n","example":"2026-09-23T09:58:12.000Z"},"economic_data_built_at":{"type":"string","format":"date-time","nullable":true,"description":"Solo con criterio económico. Construcción de purchase_economic_facts. Es vitalidad del pipeline; no es marca de agua de origen ni dice nada de completitud.","example":"2026-09-23T10:00:08.000Z"},"economic_data_status":{"type":"string","enum":["CURRENT","STALE","UNKNOWN"],"description":"Solo con criterio económico. Estado de VITALIDAD, no de completitud. CURRENT: facts se\nreconstruyó dentro de la cadencia (no certifica que estén todos los cambios de origen);\nSTALE si se han perdido dos o más ciclos de 15 min (el resultado se calculó igual, sobre la\núltima foto); UNKNOWN si la frescura no se pudo leer.\n","example":"CURRENT"},"edition_scopes":{"type":"array","description":"Solo con criterio por edición (#58: edition_spent, edition_participated). Edición y\nrevisión del vínculo usadas por cada criterio: solo miembros CONFIRMED a esa revisión.\npinned = la revisión venía en filters.edition_revision; si no, era la vigente.\n","items":{"type":"object","properties":{"edition_id":{"type":"string","example":"6ab4f2ea8f0f4cd41813aae8"},"revision":{"type":"integer","example":6},"pinned":{"type":"boolean","example":false}}}}}},"AudiencePreviewRequest":{"type":"object","required":["definitions","channels"],"properties":{"definitions":{"type":"array","minItems":1,"maxItems":20,"items":{"$ref":"#/components/schemas/Definition"},"description":"De 1 a 20 definiciones DSL (mismo formato que `/segments/preview` y\n`/segments/execute`). Se combinan con UNION DISTINCT: un fan que\ncumpla CUALQUIERA de las definiciones cuenta una sola vez en\n`unique_total`.\n"},"channels":{"type":"array","minItems":1,"items":{"type":"string","enum":["EMAIL","SMS","PUSH"]},"uniqueItems":true,"description":"Subconjunto de canales a evaluar, sin duplicados.","example":["EMAIL","SMS","PUSH"]},"consent_mode":{"type":"string","enum":["MARKETING","TRANSACTIONAL"],"default":"MARKETING","description":"`MARKETING` (default): elegibilidad de email idéntica a\n`/segments/preview` (`email_marketing_status = 'ok'`).\n`TRANSACTIONAL`: `opt_out` y `unknown` siguen siendo alcanzables para\nemail transaccional/de servicio; solo `no_email`/`invalid_email`\nexcluyen. `email_exclusions.opt_out` se omite en este modo.\n"}}},"AudienceChannelCounts":{"type":"object","description":"Conteo de elegibles por canal solicitado (solo aparecen las claves de los canales pedidos).","properties":{"email":{"type":"integer","example":612},"sms":{"type":"integer","example":430},"push":{"type":"integer","example":210}}},"AudienceEmailExclusions":{"type":"object","description":"Solo presente cuando 'EMAIL' está entre los canales solicitados.\n`opt_out` se omite cuando `consent_mode` es `TRANSACTIONAL`.\n","required":["no_email","invalid_email","unknown"],"properties":{"no_email":{"type":"integer","example":120},"invalid_email":{"type":"integer","example":45},"opt_out":{"type":"integer","description":"Omitido para consent_mode=TRANSACTIONAL","example":88},"unknown":{"type":"integer","example":135}}},"AudiencePreviewResponse":{"type":"object","required":["success","unique_total","eligible","eligible_any_channel","ineligible_all_channels","computed_at","data_as_of"],"properties":{"success":{"type":"boolean","example":true},"unique_total":{"type":"integer","description":"Fans distintos a través de todas las `definitions` (UNION, contados una vez).","example":1000},"eligible":{"allOf":[{"$ref":"#/components/schemas/AudienceChannelCounts"}],"nullable":true,"description":"Conteos elegibles por canal solicitado, o `null` cuando el mart aún no expone las columnas de contactabilidad."},"eligible_any_channel":{"type":"integer","nullable":true,"description":"Alcanzables por al menos uno de los canales solicitados, o `null` (ver `eligible`).","example":750},"ineligible_all_channels":{"type":"integer","nullable":true,"description":"`unique_total - eligible_any_channel`, o `null` (ver `eligible`).","example":250},"email_exclusions":{"allOf":[{"$ref":"#/components/schemas/AudienceEmailExclusions"}],"nullable":true},"computed_at":{"type":"string","format":"date-time","example":"2026-07-17T09:15:32.000Z"},"data_as_of":{"type":"string","format":"date-time","nullable":true,"example":"2026-07-16T03:00:00.000Z"},"economic_data_as_of":{"type":"string","format":"date-time","nullable":true,"description":"Solo si la definición usa un criterio económico (#57). Cambio de origen más reciente\nobservado a la vez en el dim de compras y en el raw (source_timestamp máximo): un máximo\nobservado, NO una marca de agua de completitud. No garantiza que estén reflejados todos\nlos cambios anteriores: una imagen que llega tarde o que el almacén retiene puede faltar\naunque el as-of sea posterior. Hoy no existe señal de completitud. Preséntese como «último\ncambio observado», nunca como «datos completos hasta». null si no se sabe. En un periodo\nsin cambios no avanza aunque el pipeline esté vivo.\n","example":"2026-09-23T09:58:12.000Z"},"economic_data_built_at":{"type":"string","format":"date-time","nullable":true,"description":"Solo con criterio económico. Construcción de purchase_economic_facts. Es vitalidad del pipeline; no es marca de agua de origen ni dice nada de completitud.","example":"2026-09-23T10:00:08.000Z"},"economic_data_status":{"type":"string","enum":["CURRENT","STALE","UNKNOWN"],"description":"Solo con criterio económico. Estado de VITALIDAD, no de completitud. CURRENT: facts se\nreconstruyó dentro de la cadencia (no certifica que estén todos los cambios de origen);\nSTALE si se han perdido dos o más ciclos de 15 min (el resultado se calculó igual, sobre la\núltima foto); UNKNOWN si la frescura no se pudo leer.\n","example":"CURRENT"}}},"EconomicDataHealth":{"type":"object","properties":{"economic_data_as_of":{"type":"string","format":"date-time","nullable":true,"description":"Cambio de origen más reciente observado (máximo, no completitud). Ver economic_data_as_of en PreviewResponse."},"economic_data_built_at":{"type":"string","format":"date-time","nullable":true,"description":"Construcción de purchase_economic_facts (vitalidad del pipeline)."},"economic_data_status":{"type":"string","enum":["CURRENT","STALE","UNKNOWN"],"description":"Vitalidad de la construcción de facts; CURRENT no certifica completitud."},"cadence_minutes":{"type":"integer","nullable":true},"max_age_minutes":{"type":"integer","nullable":true},"age_minutes":{"type":"number","nullable":true}}},"ErrorResponse":{"type":"object","required":["success","message","error"],"properties":{"success":{"type":"boolean","enum":[false],"example":false},"message":{"type":"string","description":"Mensaje genérico de fallo (fijo por endpoint, e.g. \"Failed to compute audience preview\")"},"error":{"type":"string","description":"Mensaje de error específico (validación o excepción subyacente)"},"code":{"type":"string","description":"Causa legible por máquina cuando se conoce (p. ej. ECONOMIC_METRIC_CONFIG_ERROR, error de configuración del catálogo económico, 500).","example":"ECONOMIC_METRIC_CONFIG_ERROR"}}},"CustomFieldInfo":{"type":"object","required":["property_name","data_type","display_name","total_records"],"properties":{"property_name":{"type":"string","description":"Nombre técnico del campo","example":"preferred_music_genre"},"data_type":{"type":"string","enum":["TEXT","SELECT","LIST","NUMBER","BOOLEAN","DATE"],"description":"Tipo de la definición viva del campo (no el tipo con que se escribió cada valor)"},"display_name":{"type":"string","description":"Nombre legible para UI","example":"Preferred Music Genre"},"total_records":{"type":"integer","description":"Número de registros con este campo","example":15420},"data_type_conflict":{"type":"array","items":{"type":"string"},"description":"Solo cuando el nombre tiene definiciones de tipos incompatibles en los tenants consultados; la segmentación lo rechaza con CUSTOM_FIELD_TYPE_CONFLICT","example":["LIST","TEXT"]},"definition_unverified":{"type":"boolean","description":"Solo si no se pudieron leer las definiciones; el catálogo muestra entonces el tipo del valor más reciente sin verificar"}}},"CustomFieldPropertiesResponse":{"type":"object","required":["success","custom_fields"],"properties":{"success":{"type":"boolean","example":true},"custom_fields":{"type":"array","items":{"$ref":"#/components/schemas/CustomFieldInfo"}}}},"CustomFieldDetails":{"type":"object","required":["property_name","data_type","display_name","total_records","sample_values"],"properties":{"property_name":{"type":"string","description":"Nombre técnico del campo","example":"preferred_music_genre"},"data_type":{"type":"string","enum":["TEXT","SELECT","LIST","NUMBER","BOOLEAN","DATE"]},"display_name":{"type":"string","description":"Nombre legible","example":"Preferred Music Genre"},"total_records":{"type":"integer","description":"Total de registros","example":15420},"sample_values":{"type":"array","items":{"type":"string"},"description":"Valores de muestra","example":["Rock","Pop","Jazz"]},"data_type_conflict":{"type":"array","items":{"type":"string"},"description":"Solo si el nombre tiene definiciones de tipos incompatibles en los tenants consultados (data_type es entonces el del valor más reciente). Ruta de visualización; la segmentación lo rechaza con CUSTOM_FIELD_TYPE_CONFLICT"},"definition_unverified":{"type":"boolean","description":"Solo si no se pudieron leer las definiciones; data_type es el del valor más reciente y no está verificado. La segmentación responde 503 en ese caso"}}},"CustomFieldDetailsResponse":{"type":"object","required":["success","field"],"properties":{"success":{"type":"boolean","example":true},"field":{"oneOf":[{"$ref":"#/components/schemas/CustomFieldDetails"},{"type":"null"}],"description":"Detalles del campo o null si no existe"}}},"CustomFieldValidateResponse":{"type":"object","required":["success","field"],"properties":{"success":{"type":"boolean","example":true},"field":{"type":"object","required":["property_name","data_type","exists","display_name"],"properties":{"property_name":{"type":"string","example":"preferred_music_genre"},"data_type":{"type":"string","enum":["TEXT","NUMBER","BOOLEAN","DATE"]},"exists":{"type":"boolean","example":true},"display_name":{"type":"string","example":"Preferred Music Genre"}}}}},"FilterOperator":{"type":"string","enum":["eq","neq","gt","gte","lt","lte","in","not_in","contains","not_contains","starts_with","ends_with","regex","regex_i","is_null","is_not_null","before","after","between","array_contains","array_not_contains","array_contains_any","array_length_eq","array_length_gt"],"description":"Operadores disponibles para filtros Analytics API.\n\n**Total: 28 operadores** organizados en 7 categorías.\n\n**Validación por tipo de dato**: El sistema valida automáticamente que el operador\nsea compatible con el tipo de dato del campo.\n\n### Operadores por Tipo de Dato\n\n**TEXT** (15 operadores):\n- Comparación: `eq`, `neq`, `in`, `not_in`\n- Búsqueda: `contains`, `not_contains`, `starts_with`, `ends_with`\n- Regex: `regex`, `regex_i` (case-insensitive)\n- Null: `is_null`, `is_not_null`\n\n**NUMBER** (11 operadores):\n- Comparación: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`\n- Arrays: `in`, `not_in`\n- Rangos: `between`\n- Null: `is_null`, `is_not_null`\n\n**DATE** (10 operadores):\n- Comparación: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`\n- Temporales: `before`, `after`, `between`\n- Null: `is_null`, `is_not_null`\n- El valor es `YYYY-MM-DD` o fecha y hora ISO 8601 y se compara con el cast de la columna:\n  - **Columna DATE** (`DATE(valor)`): todos los operadores comparan días. Un valor con hora\n    u offset cuenta como el **día UTC de su instante** (`2024-01-02T23:30:00-05:00` →\n    2024-01-03). `eq v` selecciona las mismas filas que `gte v` y `lte v` juntos.\n  - **Columna TIMESTAMP** (`TIMESTAMP(valor)`): `eq`, `neq`, `gt`, `gte`, `lt`, `lte`,\n    `before` y `after` comparan el **instante**; una fecha sola es su medianoche UTC. Por\n    ejemplo, `lte '2024-01-02'` no incluye el resto de ese día, y `eq '2024-01-02'` solo\n    coincide con `2024-01-02 00:00:00 UTC`. Solo `between` incluye el día UTC completo del\n    extremo final (`< día UTC siguiente`), igual que el fin de `timeRange`.\n\n**BOOLEAN** (3 operadores): `eq` / `neq` con `true` o `false`, y `exists`.\n`is_true`/`is_false` son operadores de segmentación, no de analytics (400).\n\n**ARRAY** (5 operadores):\n- Contenido: `array_contains`, `array_not_contains`, `array_contains_any`\n- Tamaño: `array_length_eq`, `array_length_gt`\n\n### Ejemplos\n\n```json\n// Text - contains\n{ \"field\": \"tenant_name\", \"operator\": \"contains\", \"value\": \"company\" }\n\n// Number - range\n{ \"field\": \"emails_sent\", \"operator\": \"in_range\", \"value\": [100, 1000] }\n\n// Date - before\n{ \"field\": \"send_date\", \"operator\": \"before\", \"value\": \"2024-12-31\" }\n\n// Regex case-insensitive\n{ \"field\": \"email\", \"operator\": \"regex_i\", \"value\": \"^[a-z]+@gmail\\\\.com$\" }\n\n// Empty check\n{ \"field\": \"description\", \"operator\": \"is_empty\" }\n```\n\n### Seguridad\n\n- **Regex**: Se valida que el patrón sea válido y no contenga construcciones peligrosas\n- **Regex y BigQuery (RE2)** (`regex`, `regex_i`): BigQuery evalúa el patrón con RE2, que no\n  admite todo lo que admite una expresión regular de JavaScript. Un conjunto enumerado de\n  construcciones incompatibles se rechaza con **400 `INVALID_ANALYTICS_VALUE` antes de lanzar\n  la consulta** y sin repetir el patrón: lookahead/lookbehind (`(?=`, `(?!`, `(?<=`, `(?<!`),\n  referencias hacia atrás (`\\1`, `\\k<n>`), escapes que RE2 no define (`\\u`, `\\c`, `\\Z`…),\n  `\\x` mal formado (sin comprobar el rango del código en `\\x{…}`), `\\E` suelto, clases POSIX desconocidas y clases sin cerrar (`[]`, `[^]`).\n  La validación **no es un parser RE2 completo**: otros patrones que RE2 no admite (por ejemplo\n  `\\p{…}` desconocido, un escape de carácter no ASCII, repeticiones de más de 1000, un rango\n  que termina en una clase como `[a-\\d]`, un código `\\x{…}` por encima de `10FFFF`) pueden\n  ser rechazados por BigQuery al ejecutar la\n  consulta, con un error de servidor y sin devolver filas.\n- **Field names**: Solo se permiten caracteres alfanuméricos, underscore y punto\n- **Array values**: Los operadores de array requieren valores no vacíos\n"},"MetricOperation":{"type":"string","enum":["sum","avg","count","min","max","count_distinct","median","percentile_25","percentile_50","percentile_75","percentile_90","percentile_95","sum_if","avg_if","count_if","string_agg","approx_top_count","date_diff","date_add","date_sub"],"description":"Operaciones disponibles para métricas:\n\n### Básicas\n- **sum**: Suma de valores numéricos\n- **avg**: Promedio de valores numéricos\n- **count**: Conteo de registros (usar field=\"*\")\n- **min**: Valor mínimo\n- **max**: Valor máximo\n\n### Agregaciones Avanzadas (Fase 3)\n- **count_distinct**: Conteo de valores únicos. Requiere field específico (no \"*\")\n- **median**: Mediana aproximada (percentil 50) usando APPROX_QUANTILES\n- **percentile_25/50/75/90/95**: Percentiles específicos aproximados\n- **approx_top_count**: Top N valores más frecuentes. Acepta params.limit (default: 10, max: 1000)\n- **string_agg**: Concatenar strings con delimitador. Acepta params.delimiter (default: \",\")\n\n### Agregaciones Condicionales\n- **sum_if**: Suma donde se cumple condición. Requiere field `condition`\n- **avg_if**: Promedio donde se cumple condición. Requiere field `condition`\n- **count_if**: Conteo donde se cumple condición. Requiere field `condition`\n\n### Phase 4 - Date Arithmetic\n- **date_diff**: Calcula `compareField − field` en la unidad pedida: `DATE_DIFF(DATE(compareField), DATE(field), unit)`. Requiere params: `compareField`, `unit` (DAY, WEEK, MONTH, QUARTER, YEAR; HOUR y MINUTE responden 400 porque la operación es sobre fechas).\n  Para «días hasta el evento» desde la creación, `field` es `created_at` y `compareField` es `start_date`.\n  Es una agregación: devuelve `AVG` de la diferencia por grupo por defecto; `params.aggregationFunction` admite `AVG`, `SUM`, `MIN` o `MAX`.\n- **date_add**: Suma un intervalo a la fecha. Requiere params: `interval`, `unit`.\n  Es una agregación: devuelve la fecha resultante `MIN` por grupo por defecto; `params.aggregationFunction` admite `MIN` o `MAX`.\n  Se puede combinar con dimensiones y otras métricas.\n- **date_sub**: Resta un intervalo a la fecha. Requiere params: `interval`, `unit`. Igual que `date_add`: `MIN` por defecto, `MIN` o `MAX`.\n- Cualquier otro valor de `aggregationFunction` responde 400 `INVALID_REQUEST` sin ejecutar la consulta.\n\n**Ejemplo date_add** (fecha del último evento de cada nombre más 7 días):\n```json\n{\n  \"field\": \"start_date\",\n  \"operation\": \"date_add\",\n  \"alias\": \"last_start_plus_7_days\",\n  \"params\": { \"interval\": 7, \"unit\": \"DAY\", \"aggregationFunction\": \"MAX\" }\n}\n```\n\n**Ejemplo date_diff**:\n```json\n{\n  \"field\": \"created_at\",\n  \"operation\": \"date_diff\",\n  \"alias\": \"days_until_event\",\n  \"params\": {\n    \"compareField\": \"start_date\",\n    \"unit\": \"DAY\"\n  }\n}\n```\n\n### Compatibilidad por tipo de dato\n\n**TEXT**: count, count_distinct, string_agg, approx_top_count\n\n**NUMBER**: sum, avg, count, min, max, count_distinct, median, percentile_*, sum_if, avg_if, count_if, approx_top_count\n\n**DATE**: count, min, max, count_distinct, count_if, approx_top_count, date_diff, date_add, date_sub\n\n**BOOLEAN**: count, count_distinct, count_if, approx_top_count\n\n### Ejemplos\n\n```json\n// COUNT_DISTINCT - Usuarios únicos\n{ \"field\": \"user_id\", \"operation\": \"count_distinct\" }\n\n// MEDIAN - Edad mediana\n{ \"field\": \"age\", \"operation\": \"median\" }\n\n// SUM_IF - Suma de ventas completadas\n{\n  \"field\": \"amount\",\n  \"operation\": \"sum_if\",\n  \"condition\": { \"field\": \"status\", \"operator\": \"eq\", \"value\": \"completed\" }\n}\n\n// STRING_AGG - Concatenar tags\n{\n  \"field\": \"tags\",\n  \"operation\": \"string_agg\",\n  \"params\": { \"delimiter\": \" | \" }\n}\n\n// APPROX_TOP_COUNT - Top 20 categorías\n{\n  \"field\": \"category\",\n  \"operation\": \"approx_top_count\",\n  \"params\": { \"limit\": 20 }\n}\n```\n"},"TimeGranularity":{"type":"string","enum":["minute","hour","day","week","month","quarter","year","fiscalQuarter","fiscalYear"],"description":"Granularidad temporal para agregaciones.\n\n**Total: 9 granularidades** (2 intra-day + 5 básicas + 2 fiscales).\n\n### Básicas (DATE_TRUNC)\nSe aplica usando `DATE_TRUNC` in the database sobre campos de fecha.\n- **day**: Agrupa por día (YYYY-MM-DD)\n- **week**: Agrupa por semana (lunes como inicio)\n- **month**: Agrupa por mes (YYYY-MM)\n- **quarter**: Agrupa por trimestre calendario (Q1: Ene-Mar, Q2: Abr-Jun, Q3: Jul-Sep, Q4: Oct-Dic)\n- **year**: Agrupa por año calendario\n\n### Phase 4 - Intra-day (TIMESTAMP_TRUNC)\nSe aplica usando `TIMESTAMP_TRUNC` para análisis de hora/minuto.\n- **hour**: Agrupa por hora (granularidad de 1 hora)\n- **minute**: Agrupa por minuto (granularidad de 1 minuto)\n\n### Phase 4 - Fiscal Periods (JOIN con dim_dates)\nUsa JOIN con tabla `dim_dates` para períodos fiscales.\n- **fiscalQuarter**: Trimestre fiscal (FQ1: Jul-Sep, FQ2: Oct-Dic, FQ3: Ene-Mar, FQ4: Abr-Jun)\n- **fiscalYear**: Año fiscal (comienza en Julio, formato: FY2024)\n- Cobertura de `dim_dates`: 2020-01-01 a 2030-12-31. Fechas fuera de ese rango, o nulas, devuelven el período `null`.\n"},"GroupByType":{"type":"string","enum":["minuteOfHour","hourOfDay","dayOfWeek","weekOfYear","day","week","month","quarter","year","fiscalQuarter","fiscalYear"],"description":"Tipos especiales de agrupación temporal para análisis de patrones.\n\n**Total: 11 tipos de groupBy** (4 extractores + 5 formateo + 2 fiscales).\n\n**Diferencia con `dimensions` y `timeGranularity`**:\n- `dimensions`: Agrupa por campos raw o con timeGranularity aplicado\n- `timeGranularity`: Transforma campos de fecha a granularidad específica (day, week, month)\n- `groupBy`: Extrae componentes temporales o aplica formateo especial sin dimensions\n\n### Temporal Extractors\nExtraen componentes de fecha/hora para análisis de patrones.\n\n- **minuteOfHour** (Phase 4): Minuto de la hora (0-59). Útil para patrones intra-hora\n  - Ejemplo: Analizar qué minutos tienen más actividad\n- **hourOfDay**: Hora del día (0-23). Útil para patrones diarios\n  - Ejemplo: Analizar qué horas tienen más compras\n- **dayOfWeek**: Día de la semana (1=Domingo, 2=Lunes, ..., 7=Sábado)\n  - Ejemplo: Comparar lunes vs viernes\n- **weekOfYear**: Número de semana ISO (1-53)\n  - Ejemplo: Identificar semanas con más actividad\n\n### Date Formatting\nFormatea fechas para agrupación sin usar dimensions.\n\n- **day**: Fecha completa (YYYY-MM-DD)\n- **week**: Semana (YYYY-WXX)\n- **month**: Mes (YYYY-MM)\n- **quarter**: Trimestre (YYYY-QX)\n- **year**: Año (YYYY)\n\n### Phase 4 - Fiscal Periods\nAgrupación por períodos fiscales usando tabla dim_dates.\n\n- **fiscalQuarter**: Trimestre fiscal (formato: FY2024-FQ1)\n  - FQ1: Julio-Septiembre\n  - FQ2: Octubre-Diciembre\n  - FQ3: Enero-Marzo\n  - FQ4: Abril-Junio\n- **fiscalYear**: Año fiscal (formato: FY2024)\n  - Comienza en Julio, termina en Junio siguiente\n- Cobertura de `dim_dates`: 2020-01-01 a 2030-12-31. Fechas fuera de ese rango, o nulas, devuelven el período `null`.\n\n### Ejemplos\n\n```json\n// Análisis por hora del día (sin dimensions)\n{\n  \"collection\": \"purchases\",\n  \"groupBy\": [\"hourOfDay\"],\n  \"metrics\": [\n    {\"field\": \"*\", \"operation\": \"count\", \"alias\": \"purchase_count\"}\n  ]\n}\n\n// Análisis por día de la semana\n{\n  \"collection\": \"events\",\n  \"groupBy\": [\"dayOfWeek\"],\n  \"metrics\": [\n    {\"field\": \"id\", \"operation\": \"count\", \"alias\": \"event_count\"}\n  ]\n}\n\n// Análisis por trimestre fiscal\n{\n  \"collection\": \"purchases\",\n  \"groupBy\": [\"fiscalQuarter\"],\n  \"metrics\": [\n    {\"field\": \"total_price_eur\", \"operation\": \"sum\", \"alias\": \"revenue\"}\n  ]\n}\n```\n"},"SortOrder":{"type":"string","enum":["asc","desc"],"description":"Orden de clasificación"},"FilterCondition":{"type":"object","required":["field","operator","value"],"properties":{"field":{"type":"string","description":"Nombre del campo a filtrar","example":"send_date"},"operator":{"$ref":"#/components/schemas/FilterOperator"},"value":{"oneOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"array","items":{"oneOf":[{"type":"string"},{"type":"number"}]}}],"description":"Valor para la comparación. El tipo depende del operador:\n\n- **Comparación**: string, number, o boolean\n- **Arrays** (`in`, `nin`): array de valores\n- **Fechas**: string ISO 8601, `YYYY-MM-DD` o fecha y hora (`Z`, offset o sin zona = UTC).\n  Cómo se compara según el tipo de la columna: ver `FilterOperator` (DATE)\n- **Between**: array con [inicio, fin]\n","example":"2024-01-01"}}},"Dimension":{"type":"object","required":["field"],"properties":{"field":{"type":"string","description":"Nombre del campo para agrupar","example":"send_date"},"alias":{"type":"string","description":"Alias para el campo en los resultados","example":"fecha"}}},"Metric":{"type":"object","required":["field","operation"],"properties":{"field":{"type":"string","description":"Nombre del campo para agregar.\n\nUsar \"*\" para operaciones COUNT.\n","example":"emails_sent"},"operation":{"$ref":"#/components/schemas/MetricOperation"},"alias":{"type":"string","description":"Alias para la métrica en los resultados","example":"total_emails"},"condition":{"$ref":"#/components/schemas/FilterCondition","description":"Condición para agregaciones condicionales (sum_if, avg_if, count_if).\n\n**Requerido** para operaciones: sum_if, avg_if, count_if\n"},"params":{"type":"object","description":"Parámetros adicionales para operaciones específicas","properties":{"delimiter":{"type":"string","description":"Delimitador para STRING_AGG (default: \",\").\n\nNo puede contener: comillas, punto y coma, backslashes\n","example":" | "},"percentile":{"type":"number","description":"Percentil personalizado (0-100) - RESERVADO para futuro uso","minimum":0,"maximum":100},"limit":{"type":"integer","description":"Límite de resultados para APPROX_TOP_COUNT (default: 10).\n\nDebe estar entre 1 y 1000\n","minimum":1,"maximum":1000,"example":20}}}}},"TimeRange":{"type":"object","required":["start","end"],"properties":{"start":{"type":"string","description":"Inicio del rango: `YYYY-MM-DD`, o fecha y hora ISO 8601 (con `Z`, con offset\n`±HH:MM` o sin zona, que se toma como UTC).\n","example":"2024-01-01"},"end":{"type":"string","description":"Fin del rango, en las mismas formas que `start`. Se incluye el día UTC completo\nde `end`.\n","example":"2024-12-31"}},"description":"**Phase 4**: Rango temporal para filtrar datos.\n\nAutomáticamente filtra usando el campo temporal principal de la colección (en un CTE,\nel de la colección del CTE o su `timeField`). El SQL depende del tipo de la columna:\n\n- **TIMESTAMP:** `campo >= TIMESTAMP(start) AND campo < TIMESTAMP(<día UTC siguiente al de end>)`.\n  El inicio es el instante dado (una fecha sola es su medianoche UTC; si trae hora, se\n  conserva). El fin incluye el día UTC completo del instante `end`.\n- **DATE:** `campo >= DATE(start) AND campo <= DATE(end)`. Con hora u offset, cada extremo\n  cuenta como el **día UTC de su instante**: `2024-01-02T23:30:00-05:00` es el 3 de enero;\n  `2024-01-02T00:30:00+02:00`, el 1 de enero; sin zona, UTC.\n\nLos días son días UTC: un rango diario no coincide con el día local de una zona con offset.\n"},"CTE":{"type":"object","required":["name","collection"],"properties":{"name":{"type":"string","pattern":"^[a-zA-Z0-9_]+$","description":"Nombre del CTE (alphanumeric + underscores only).\n\nDebe ser único dentro de la query. Máximo 3 CTEs.\n","example":"tickets_per_purchase"},"collection":{"type":"string","description":"Tabla del mart (debe estar en la whitelist). Un CTE que lee otro CTE\nno está soportado: 400 `INVALID_REQUEST`. El `sourceTable` de la query\nprincipal sí puede ser un CTE.\n","example":"purchase_tickets"},"dimensions":{"type":"array","items":{"$ref":"#/components/schemas/Dimension"},"description":"Dimensiones para el CTE (GROUP BY)"},"metrics":{"type":"array","items":{"$ref":"#/components/schemas/Metric"},"description":"Métricas para el CTE (agregaciones)"},"filters":{"type":"array","items":{"$ref":"#/components/schemas/FilterCondition"},"description":"Filtros para el CTE (WHERE)"},"timeRange":{"$ref":"#/components/schemas/TimeRange"},"timeField":{"type":"string","description":"Campo temporal del `timeRange` del CTE (solo se lee si hay `timeRange`). Por\ndefecto, el campo temporal de la colección del propio CTE; si la colección no\ntiene, `timeRange` exige `timeField` (400 si falta). Debe ser una columna de esa\ntabla y de tipo DATE o TIMESTAMP: un nombre que no existe en el mart o que no es\ncolumna de la tabla da 400 `INVALID_FIELD`, y uno de otro tipo, 400\n`INVALID_REQUEST`. Se aceptan los alias del mapper (`start_date` → `start_ts`).\nUna columna sin tipo declarado se acepta solo si su nombre termina en `_at`, `_ts`\no `_date`, o contiene `timestamp`. Límite: un nombre con esa forma que no es\ncolumna de la tabla (p. ej. `zz_at`) no se detecta y falla en BigQuery.\n"},"timeGranularity":{"$ref":"#/components/schemas/TimeGranularity"},"sort":{"$ref":"#/components/schemas/SortInput"},"limit":{"type":"integer","minimum":1,"description":"Límite de resultados del CTE"},"groupBy":{"type":"array","items":{"$ref":"#/components/schemas/GroupByType"},"description":"No soportado dentro de un CTE: si trae valores, 400 `INVALID_REQUEST`.\nEl GROUP BY del CTE sale de sus `dimensions`.\n"}},"description":"**Phase 4 - Common Table Expression (CTE)**.\n\nPermite nested aggregations complejas sin hardcoded SQL.\n\n**Validación**:\n- Nombre alfanumérico + underscores\n- No duplicados\n- Al menos 1 dimension o metric\n- Collection debe estar en whitelist; un CTE que lee otro CTE es un 400\n- `groupBy` no está soportado dentro de un CTE (400)\n- Automatic tenant_id filter injected\n\n**Ejemplo - Group Size Distribution**:\n```json\n{\n  \"ctes\": [\n    {\n      \"name\": \"tickets_per_purchase\",\n      \"collection\": \"purchase_tickets\",\n      \"dimensions\": [{\"field\": \"purchase_id\"}],\n      \"metrics\": [{\"field\": \"id\", \"operation\": \"count\", \"alias\": \"group_size\"}],\n      \"filters\": [{\"field\": \"event_id\", \"operator\": \"eq\", \"value\": \"690b9cfb...\"}]\n    }\n  ],\n  \"sourceTable\": \"tickets_per_purchase\",\n  \"collection\": \"purchase_tickets\",\n  \"dimensions\": [{\"field\": \"group_size\"}],\n  \"metrics\": [{\"field\": \"purchase_id\", \"operation\": \"count\", \"alias\": \"purchase_count\"}],\n  \"sort\": {\"field\": \"group_size\", \"order\": \"asc\"}\n}\n```\n"},"SortConfig":{"type":"object","required":["field"],"properties":{"field":{"type":"string","description":"Campo por el cual ordenar","example":"month"},"order":{"$ref":"#/components/schemas/SortOrder"}}},"SortInput":{"description":"Configuración de ordenamiento. Acepta un único objeto (retrocompatible) o un array\nde objetos para ordenación por múltiples campos.\n\n**Formato simple (retrocompatible):**\n```json\n{ \"field\": \"revenue\", \"order\": \"desc\" }\n```\n\n**Formato multi-sort (nuevo):**\n```json\n[\n  { \"field\": \"revenue\", \"order\": \"desc\" },\n  { \"field\": \"event_name\", \"order\": \"asc\" }\n]\n```\n\nEl campo `order` es opcional y toma valor `\"asc\"` por defecto.\n","oneOf":[{"$ref":"#/components/schemas/SortConfig"},{"type":"array","items":{"$ref":"#/components/schemas/SortConfig"},"minItems":1}],"example":[{"field":"revenue","order":"desc"},{"field":"event_name","order":"asc"}]},"PeriodComparison":{"type":"object","required":["active","current","previous"],"properties":{"active":{"type":"boolean","description":"Si está activa la comparación de períodos"},"current":{"type":"object","required":["start","end"],"properties":{"start":{"type":"string","format":"date","description":"Fecha de inicio del período actual (YYYY-MM-DD)","example":"2025-12-01"},"end":{"type":"string","format":"date","description":"Fecha de fin del período actual (YYYY-MM-DD)","example":"2025-12-31"},"name":{"type":"string","description":"Nombre opcional del período actual para UI","example":"Diciembre 2025"}}},"previous":{"type":"object","required":["start","end"],"properties":{"start":{"type":"string","format":"date","description":"Fecha de inicio del período anterior (YYYY-MM-DD)","example":"2024-12-01"},"end":{"type":"string","format":"date","description":"Fecha de fin del período anterior (YYYY-MM-DD)","example":"2024-12-31"},"name":{"type":"string","description":"Nombre opcional del período anterior para UI","example":"Diciembre 2024"}}}},"description":"**✅ IMPLEMENTADO v3.10.1**: Comparación período-sobre-período (Period-over-Period).\n\n## ¿Qué hace?\nCompara métricas entre dos períodos de tiempo diferentes. Ideal para análisis Year-over-Year (YoY), Month-over-Month (MoM), Week-over-Week (WoW), etc.\n\n## Columnas generadas\nPara cada métrica (ej. `revenue`), se generan automáticamente:\n- `{metric}_current` - Valor del período actual\n- `{metric}_previous` - Valor del período anterior\n- `{metric}_change` - Diferencia absoluta (current - previous)\n- `{metric}_change_percent` - Cambio porcentual ((current - previous) / previous * 100)\n\n## Ejemplo de uso\n```json\n{\n  \"active\": true,\n  \"current\": {\n    \"start\": \"2025-12-01\",\n    \"end\": \"2025-12-31\",\n    \"name\": \"Diciembre 2025\"\n  },\n  \"previous\": {\n    \"start\": \"2024-12-01\",\n    \"end\": \"2024-12-31\",\n    \"name\": \"Diciembre 2024\"\n  }\n}\n```\n\n## Response esperado\n```json\n{\n  \"data\": [\n    {\n      \"purchased_at\": \"2025-12-01\",\n      \"revenue_current\": 15000,\n      \"revenue_previous\": 12000,\n      \"revenue_change\": 3000,\n      \"revenue_change_percent\": 25.0\n    }\n  ]\n}\n```\n\n## ⚠️ Importante\n- NO incluir `timeRange` cuando `comparePeriods.active = true`\n- Los períodos se definen en `current` y `previous`\n- Soporta todas las métricas y granularidades temporales\n"},"DimensionComparison":{"type":"object","required":["active","dimensions"],"properties":{"active":{"type":"boolean","description":"Si está activa la comparación de dimensiones","example":true},"dimensions":{"type":"array","minItems":2,"maxItems":10,"items":{"type":"object","required":["field","value"],"properties":{"field":{"type":"string","description":"Campo de la dimensión a comparar","example":"eventId"},"value":{"type":"string","description":"Valor específico a comparar (ID de la entidad)","example":"6940193688d2ee658320f9e5"},"name":{"type":"string","description":"Nombre legible para la entidad (opcional)","example":"Euforia Festival 2025"}}},"description":"Array de objetos con los valores a comparar.\n\nMínimo 2 elementos, máximo 10 elementos.\n"},"useRelativeTime":{"type":"boolean","description":"**✅ NUEVO v3.9.1**: Usar tiempo relativo al inicio de cada entidad.\n\nCuando `true`, la API alinea las fechas de cada entidad al inicio de su evento/período,\npermitiendo comparar entidades con fechas de inicio diferentes.\n\nEjemplo: Comparar dos eventos con fechas de inicio distintas alineados por \"días desde inicio\".\n","default":false,"example":false}},"description":"**✅ IMPLEMENTADO v3.10.1**: Comparación entidad-vs-entidad (Dimension Comparison).\n\n## ¿Qué hace?\nCompara métricas entre múltiples valores de una dimensión. Ideal para comparar eventos, categorías, productos, etc.\n\n## Columnas generadas\nPara cada métrica (ej. `revenue`) y cada valor de comparación, se genera:\n- `{metric}_{value}` - Métrica para ese valor específico\n- `{metric}_total` - Suma total de todas las entidades\n\n## Response con metadata\nLa API devuelve `metadata.comparisonMetadata` con información para mapear columnas:\n```json\n{\n  \"comparisonMetadata\": {\n    \"comparisonDimensions\": [\n      {\n        \"field\": \"event_id\",\n        \"value\": \"6940193688d2ee658320f9e5\",\n        \"label\": \"6940193688d2ee658320f9e5\",\n        \"columnName\": \"revenue_6940193688d2ee658320f9e5\"\n      }\n    ],\n    \"totalColumn\": \"revenue_total\"\n  }\n}\n```\n\n## Ejemplo de uso (FORMATO CORRECTO)\n```json\n{\n  \"active\": true,\n  \"dimensions\": [\n    { \"field\": \"eventId\", \"value\": \"6940193688d2ee658320f9e5\" },\n    { \"field\": \"eventId\", \"value\": \"693724b1fca82f737fafa77d\" },\n    { \"field\": \"eventId\", \"value\": \"690b9cfb0640eb7992afd03e\" }\n  ]\n}\n```\n\n## Response esperado\n```json\n{\n  \"data\": [\n    {\n      \"purchased_at\": \"2025-12-01\",\n      \"revenue_6940193688d2ee658320f9e5\": 15000,\n      \"revenue_693724b1fca82f737fafa77d\": 12000,\n      \"revenue_690b9cfb0640eb7992afd03e\": 8000,\n      \"revenue_total\": 35000\n    }\n  ]\n}\n```\n\n## ⚠️ Importante\n- NO incluir el campo de comparación en `filters` cuando `active = true`\n- La API filtra automáticamente por los valores en `dimensions` array\n- Mínimo 2, máximo 10 elementos en array `dimensions`\n- Frontend debe usar `comparisonMetadata` para mapeo dinámico de columnas\n- Ejemplo validado en: `postman/Event-Dashboard-Complete-v1.2-Fixed.postman_collection.json` (Sección 6)\n"},"ComparisonDimensionMetadata":{"type":"object","required":["field","value","columnName"],"properties":{"field":{"type":"string","description":"Campo que se está comparando (ej. \"event_id\")","example":"event_id"},"value":{"type":"string","description":"Valor de la dimensión para esta comparación","example":"6597d9b3c9a57d0f00fcf194"},"label":{"type":"string","description":"Label legible para mostrar en UI.\n\nActualmente contiene el mismo valor que `value`.\nEn futuras versiones incluirá el nombre de la entidad (ej. nombre del evento).\n","example":"6597d9b3c9a57d0f00fcf194"},"columnName":{"type":"string","description":"Nombre de la columna generada en el resultado.\n\nUsar este valor para extraer la serie de datos del array de resultados.\n","example":"revenue_6597d9b3c9a57d0f00fcf194"}},"description":"**NEW v3.9.1**: Metadata para un valor de comparación individual.\n\nMapea un valor de dimensión a su columna generada en la respuesta.\n"},"ComparisonMetadata":{"type":"object","required":["comparisonDimensions","totalColumn"],"properties":{"comparisonDimensions":{"type":"array","items":{"$ref":"#/components/schemas/ComparisonDimensionMetadata"},"description":"Array con metadata para cada valor de comparación"},"totalColumn":{"type":"string","description":"Nombre de la columna con el total agregado de todas las comparaciones","example":"revenue_total"}},"description":"**NEW v3.9.1**: Metadata para facilitar el mapeo de columnas dinámicas en comparaciones.\n\n## Cuándo se incluye\nSe incluye automáticamente en `metadata.comparisonMetadata` cuando `compareDimensions.active = true`.\n\n## Beneficios para frontend\n\n1. **Mapeo Directo**: Acceso a series sin manipulación de strings\n   ```javascript\n   const { comparisonDimensions } = response.data.metadata.comparisonMetadata;\n   comparisonDimensions.forEach(({ columnName, label }) => {\n     const seriesData = response.data.data.map(row => ({\n       date: row.purchased_at,\n       value: row[columnName]  // Acceso directo sin parsing\n     }));\n   });\n   ```\n\n2. **Charting Dinámico**: Configuración de gráficos sin hardcoding\n   ```javascript\n   const chartConfig = {\n     lines: comparisonDimensions.map(({ columnName, label }) => ({\n       dataKey: columnName,\n       name: label\n     }))\n   };\n   ```\n\n3. **Cálculos de Porcentaje**: Referencia directa a columna total\n   ```javascript\n   const { totalColumn } = response.data.metadata.comparisonMetadata;\n   data.forEach(row => {\n     const percentage = (row[columnName] / row[totalColumn]) * 100;\n   });\n   ```\n\n## Ejemplo completo\n```json\n{\n  \"comparisonDimensions\": [\n    {\n      \"field\": \"event_id\",\n      \"value\": \"6597d9b3c9a57d0f00fcf194\",\n      \"columnName\": \"revenue_6597d9b3c9a57d0f00fcf194\",\n      \"label\": \"6597d9b3c9a57d0f00fcf194\"\n    },\n    {\n      \"field\": \"event_id\",\n      \"value\": \"666dea428de8c802f52a8be1\",\n      \"columnName\": \"revenue_666dea428de8c802f52a8be1\",\n      \"label\": \"666dea428de8c802f52a8be1\"\n    }\n  ],\n  \"totalColumn\": \"revenue_total\"\n}\n```\n"},"FilterQuery":{"type":"object","required":["collection","fields"],"properties":{"collection":{"type":"string","description":"Nombre de la vista/tabla in the database.\n\nEjemplos: `email_usage_daily`, `purchases`, `events`\n\nDebe ser una tabla de la lista blanca de la Analytics API (la que publica\n`GET /analytics/capabilities`). Cualquier otra es un 400 `INVALID_REQUEST`\npara toda la petición, antes de consultar BigQuery; el mensaje no repite el nombre.\n","example":"email_usage_daily"},"fields":{"type":"array","items":{"type":"string"},"description":"Lista de campos para obtener valores únicos","example":["tenant_name","tier_name"]},"filters":{"type":"array","items":{"$ref":"#/components/schemas/FilterCondition"},"description":"Filtros adicionales para limitar los valores"}}},"FilterRequest":{"type":"object","required":["queries"],"properties":{"queries":{"type":"array","items":{"$ref":"#/components/schemas/FilterQuery"},"description":"Lista de consultas para obtener valores de filtros"}}},"FilterResponse":{"type":"object","required":["collection","results"],"properties":{"collection":{"type":"string","description":"Nombre de la colección consultada","example":"email_usage_daily"},"results":{"type":"object","additionalProperties":{"type":"array"},"description":"Resultados organizados por campo.\n\nCada clave es un nombre de campo, cada valor es un array de valores únicos.\n","example":{"tenant_name":["Empresa A","Empresa B"],"tier_name":["Basic","Professional","Enterprise"]}}}},"QueryRequest":{"type":"object","required":["collection"],"properties":{"collection":{"type":"string","description":"Nombre de la vista/tabla base a consultar.\n\nDebe ser una tabla de la lista blanca de la Analytics API (la que publica\n`GET /analytics/capabilities`). Cualquier otra es un 400 `INVALID_REQUEST`\nantes de consultar BigQuery, también en `dryRun` y en `/analytics/export`;\nel mensaje no repite el nombre.\n","example":"email_usage_daily"},"ctes":{"type":"array","maxItems":3,"items":{"$ref":"#/components/schemas/CTE"},"description":"**Phase 4 - CTEs (Common Table Expressions)**: Nested aggregations sin hardcoded SQL.\n\nPermite queries complejas con agregaciones multi-nivel. Máximo 3 CTEs por query.\n\n**Casos de uso**:\n- Group size distribution (count tickets per purchase, then count purchases per group size)\n- Cohort analysis (first purchase date, then analyze by cohort)\n- Top performers (rank by metric, then aggregate ranked results)\n"},"sourceTable":{"type":"string","description":"**Phase 4 - CTEs**: Indica desde qué tabla/CTE se debe hacer el SELECT principal.\n\nSi se especifica, el SELECT principal se hace desde este CTE en lugar de `collection`.\nLos filtros del main query NO se aplican cuando sourceTable es un CTE (data ya filtrada).\nSi no es uno de los CTE, debe ser una tabla de la lista blanca: si no, 400\n`INVALID_REQUEST` sin repetir el nombre.\n","example":"tickets_per_purchase"},"dimensions":{"type":"array","items":{"$ref":"#/components/schemas/Dimension"},"description":"Campos para agrupar (GROUP BY)"},"metrics":{"type":"array","items":{"$ref":"#/components/schemas/Metric"},"description":"Campos para agregar (SUM, COUNT, etc.)"},"timeGranularity":{"$ref":"#/components/schemas/TimeGranularity"},"groupBy":{"type":"array","items":{"$ref":"#/components/schemas/GroupByType"},"description":"**Phase 4**: Tipos especiales de agrupación temporal (temporal extractors y fiscal periods).\n\n**Diferencia con dimensions**:\n- `groupBy`: Extrae componentes (hourOfDay, dayOfWeek) o aplica formateo fiscal\n- `dimensions`: Agrupa por campos raw o con timeGranularity aplicado\n\nEjemplos: hourOfDay, dayOfWeek, fiscalQuarter, fiscalYear\n"},"timeRange":{"$ref":"#/components/schemas/TimeRange"},"filters":{"type":"array","items":{"$ref":"#/components/schemas/FilterCondition"},"description":"Condiciones de filtrado (WHERE)"},"having":{"type":"array","items":{"$ref":"#/components/schemas/FilterCondition"},"description":"Filtros sobre resultados agregados (HAVING).\n\nSolo se puede usar cuando hay métricas o dimensiones.\n"},"sort":{"$ref":"#/components/schemas/SortInput"},"limit":{"type":"integer","minimum":1,"maximum":10000,"description":"Límite de resultados","example":100},"offset":{"type":"integer","minimum":0,"description":"Número de filas del resultado final a saltar (paginación, #62 R-2). Se aplica tras\n`having`, `sort` y las comparativas, y va como parámetro (`LIMIT … OFFSET …`).\n\n- Las páginas solo son estables con un `sort` que ordene todas las filas (p. ej. por\n  un id único); sin `sort` el orden no está garantizado.\n- Con `limit`: esa página. Sin `limit`: salta `offset` filas del resultado completo\n  (sin tope, como sin `offset`).\n- `0` o ausente: el resultado de siempre.\n- `offset > 0` con `groupBy` `dayOfWeek`/`hourOfDay` → 400 `INVALID_REQUEST`: esas\n  franjas se rellenan después de la consulta y las páginas se solaparían.\n- Un valor que no sea un entero ≥ 0 → 400 `INVALID_ANALYTICS_VALUE`.\n- No aplica a `ctes[]`, `/analytics/export` ni `/analytics/filters`.\n","example":0},"distinct":{"type":"boolean","default":false,"description":"**Deduplicación a nivel de query (NEV-1597).**\n\nCuando `true`, genera `SELECT DISTINCT` en lugar de `SELECT`, eliminando filas duplicadas del resultado.\n\n**Compatibilidad:**\n- ✅ Funciona con `dimensions`, `filters`, `sort`, `limit`\n- ⚠️ Ignorado silenciosamente si hay `metrics` con funciones de agregación (COUNT, SUM, etc.)\n  — en ese caso se incluye un `warning` en `metadata.warnings`\n- ⚠️ No disponible con `comparePeriods` ni `compareDimensions`\n- 🔄 Valor por defecto: `false` (retrocompatible)\n\n**Ejemplo de uso:**\n```json\n{\n  \"collection\": \"user_tenants\",\n  \"dimensions\": [{ \"field\": \"city\" }],\n  \"distinct\": true\n}\n```\n\nGenera:\n```sql\nSELECT DISTINCT city\nFROM `nevent-daf5f.mart_nevent_dev.user_tenants`\nWHERE tenant_id = 'xxx'\n```\n","example":true},"comparePeriods":{"$ref":"#/components/schemas/PeriodComparison"},"compareDimensions":{"$ref":"#/components/schemas/DimensionComparison"}}},"AnalyticsDryRunResponse":{"type":"object","description":"Respuesta de un dry run de BigQuery. Valida la sintaxis SQL y estima el coste\nsin ejecutar la query real (coste = $0). Devuelto cuando se usa `?dryRun=true`.\n","required":["valid","estimatedBytesProcessed","estimatedBytesProcessedFormatted","estimatedCostUSD","query"],"properties":{"valid":{"type":"boolean","description":"Si la query es sintácticamente válida según BigQuery","example":true},"estimatedBytesProcessed":{"type":"number","description":"Bytes estimados que se procesarían al ejecutar la query","example":1258291200},"estimatedBytesProcessedFormatted":{"type":"string","description":"Bytes estimados en formato legible (B, KB, MB, GB, TB)","example":"1.17 GB"},"estimatedCostUSD":{"type":"number","description":"Coste estimado en USD (BigQuery on-demand pricing, $6.25/TB)","example":0.007153},"query":{"type":"string","description":"SQL generado que se validaría en BigQuery","example":"SELECT `event_id`, SUM(`total_price`) AS `revenue` FROM `nevent-daf5f.mart_nevent_dev.purchases` WHERE `tenant_id` IN ('abc123') GROUP BY `event_id`"}}},"QueryResponse":{"type":"object","required":["data"],"properties":{"data":{"type":"array","items":{"type":"object"},"description":"Array de resultados de la consulta.\n\nCada objeto contiene los campos solicitados en dimensiones y métricas.\n"},"metadata":{"type":"object","properties":{"totalRows":{"type":"integer","description":"Número total de filas retornadas","example":150},"executionTime":{"type":"integer","description":"Tiempo de ejecución en millisegundos","example":1263},"query":{"type":"string","description":"SQL generado que se ejecutó in the database","example":"SELECT SUM(`emails_sent`) as `total_emails` FROM `nevent-daf5f.mart_nevent_dev.email_usage_daily` WHERE `tenant_id` = '72ef6ce8-...' AND `send_date` >= DATE('2024-01-01')"},"comparisonMetadata":{"$ref":"#/components/schemas/ComparisonMetadata","description":"**NEW v3.9.1**: Metadata para facilitar mapeo de columnas dinámicas en comparaciones.\n\nSe incluye automáticamente cuando `compareDimensions.active = true`.\n\nProporciona:\n- Mapeo de valores de comparación a nombres de columnas generadas\n- Nombre de la columna de total agregado\n- Labels para series de gráficos (futuro: incluirá nombres de entidades)\n"},"warnings":{"type":"array","items":{"type":"string"},"description":"**NEV-1597**: Warnings sobre parámetros de la consulta ignorados o ajustados.\n\nEjemplos:\n- `distinct` ignorado porque hay métricas de agregación presentes\n","example":["distinct was ignored because aggregation metrics (COUNT, SUM, etc.) are present."]}}}}},"HealthCheckResponse":{"type":"object","required":["success","data","timestamp"],"properties":{"success":{"type":"boolean","example":true},"data":{"type":"object","required":["status","database","timestamp"],"properties":{"status":{"type":"string","enum":["ok","error"],"description":"Estado general del servicio"},"database":{"type":"object","required":["connected","projectId"],"properties":{"connected":{"type":"boolean","description":"Si hay conectividad con database"},"projectId":{"type":"string","description":"Cloud project ID","example":"nevent-daf5f"},"datasets":{"type":"array","items":{"type":"string"},"description":"Datasets disponibles in the database","example":["mart_nevent_dev","mart_nevent_prd"]}}},"timestamp":{"type":"string","format":"date-time","description":"Timestamp del check"}}},"timestamp":{"type":"string","format":"date-time","description":"Timestamp de la respuesta"}}},"AuthError":{"type":"object","description":"Error del middleware de autenticación (antes de llegar al endpoint).","required":["success","message"],"properties":{"success":{"type":"boolean","example":false},"message":{"type":"string","example":"No tienes acceso a la organización seleccionada"},"code":{"type":"string","description":"Código estable de los errores de ámbito de tenant.","enum":["INVALID_TOKEN_TYPE","TENANT_CLAIM_MISSING","INVALID_ACTIVE_TENANT","ACTIVE_TENANT_FORBIDDEN","IMPERSONATION_SESSION_TERMINATED","TENANT_HIERARCHY_UNAVAILABLE","TENANT_SCOPE_RESOLUTION_FAILED"]}}},"ApiError":{"type":"object","required":["success","error","timestamp"],"properties":{"success":{"type":"boolean","example":false},"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Código de error","example":"INVALID_REQUEST"},"message":{"type":"string","description":"Mensaje de error legible","example":"Collection is required"},"details":{"description":"Detalles adicionales del error"}}},"timestamp":{"type":"string","format":"date-time"}}}},"responses":{"SalesProvenanceBadRequest":{"description":"Parámetro inválido (INVALID_DATE, INVALID_RANGE, INVALID_TZ, INVALID_CURRENCY, INVALID_POPULATION, INVALID_DIMENSION, INVALID_LEVEL, INVALID_PARENT, INVALID_KEY, INVALID_PARAM, INVALID_EVENT)","content":{"application/json":{"example":{"success":false,"error":{"code":"INVALID_RANGE","message":"from y to son obligatorios"},"timestamp":"2026-09-28T12:00:00.000Z"}}}},"SalesProvenanceNotFound":{"description":"El evento no existe o no pertenece al ámbito del JWT (indistinguible)","content":{"application/json":{"example":{"success":false,"error":{"code":"EVENT_NOT_FOUND","message":"Evento no encontrado"},"timestamp":"2026-09-28T12:00:00.000Z"}}}},"SalesProvenanceServerError":{"description":"INVARIANT_VIOLATION (el desglose no concilia; no se devuelven cifras parciales) o INTERNAL_ERROR","content":{"application/json":{"example":{"success":false,"error":{"code":"INVARIANT_VIOLATION","message":"Los datos no concilian; no se muestran cifras parciales"},"timestamp":"2026-09-28T12:00:00.000Z"}}}},"SegmentValidationError":{"description":"Solicitud rechazada antes de ejecutar ninguna consulta. `code` es estable y legible por\nmáquina (el texto de `error` puede cambiar); la lista es abierta, así que un cliente trata un\ncódigo que no conoce como un fallo de la solicitud, no de la definición del segmento.\nAlgunos códigos:\n- `INVALID_PAGINATION`: `page`/`page_size` (o `after_user_id` en `/segments/execute-ids`)\n  fuera de su contrato. Es un error de quien llama, no de la definición.\n- `UNAVAILABLE_CRITERION`: el criterio existe pero no está disponible en este entorno.\n- `INVALID_CRITERION_VALUE`: un valor de criterio no encaja con su tipo (número no\n  representable, fecha que BigQuery no convierte, lista vacía donde se exige un valor…).\n  Todos los valores de la DSL viajan como parámetros de BigQuery, nunca interpolados.\n- `INVALID_GROUPS`: `groups.total_groups` fuera de 1–100 o `group_number` fuera de rango.\n- `INVALID_AVERAGE_THRESHOLD`: umbral no válido de un criterio AVG monetario (CURRENCY), sea la\n  media exacta o la media económica canónica (`average_spent` antes y después de migrar el\n  catálogo). Se aplica después de `INVALID_CRITERION_VALUE`, que ya rechaza lo que no es un\n  número finito cuyos céntimos sean un entero seguro (|céntimos| < 2^53); la media rechaza\n  además el texto numérico que no es decimal (`\"0x10\"`, `\"0b1\"`) y un `between` que no sea un\n  par `[mínimo, máximo]` (p. ej. `{start, end}`). `is_set` e `is_not_set` no llevan umbral.\n","content":{"application/json":{"schema":{"type":"object","required":["success","message","error","code"],"properties":{"success":{"type":"boolean","example":false},"message":{"type":"string"},"error":{"type":"string"},"code":{"type":"string","description":"Código estable del error (lista abierta).","example":"INVALID_CRITERION_VALUE"}}},"examples":{"invalidCriterionValue":{"summary":"Valor de criterio inválido","value":{"success":false,"message":"Failed to preview segment","error":"El operador 'gt' requiere un número finito representable","code":"INVALID_CRITERION_VALUE"}},"invalidGroups":{"summary":"Grupos fuera de rango","value":{"success":false,"message":"Failed to execute segment","error":"total_groups debe ser un entero entre 1 y 100","code":"INVALID_GROUPS"}},"invalidAverageThresholdPreview":{"summary":"Umbral de media no decimal (preview)","value":{"success":false,"message":"Failed to preview segment","error":"El umbral de la media del criterio 'average_spent' debe ser un número (recibido: \"0x10\")","code":"INVALID_AVERAGE_THRESHOLD"}},"invalidAverageThresholdExecute":{"summary":"Umbral de media no decimal (execute)","value":{"success":false,"message":"Failed to execute segment","error":"El umbral de la media del criterio 'average_spent' debe ser un número (recibido: \"0x10\")","code":"INVALID_AVERAGE_THRESHOLD"}}}}}},"BadRequest":{"description":"Solicitud inválida","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"examples":{"missing_collection":{"summary":"Colección requerida","value":{"success":false,"error":{"code":"INVALID_REQUEST","message":"Collection is required"},"timestamp":"2025-09-27T16:00:00.000Z"}},"invalid_dates":{"summary":"Fechas inválidas","value":{"success":false,"error":{"code":"INVALID_REQUEST","message":"Invalid dates in current period"},"timestamp":"2025-09-27T16:00:00.000Z"}},"having_without_grouping":{"summary":"HAVING sin agrupación","value":{"success":false,"error":{"code":"INVALID_REQUEST","message":"Having clause requires dimensions, groupBy or metrics for aggregation"},"timestamp":"2025-09-27T16:00:00.000Z"}},"invalid_field":{"summary":"Campo inexistente en el mart","description":"`phone_verified`, `instagram_connected`, `tiktok_connected` y `mobile_app_downloaded`\nno existen en ninguna tabla del mart. En cualquier posición (dimensión, filtro,\nmétrica, condición de métrica, orden, CTE), con o sin tabla (`user_tenants.phone_verified`),\nse rechazan antes de ejecutar nada, también con `dryRun=true` y como `compareField`\nde `date_diff`. `POST /analytics/filters` (en `queries[].fields`) y `POST /analytics/export`\nresponden el mismo 400 `INVALID_FIELD` con su propio `message` («Invalid filter values\nrequest» / «Analytics export failed»). Una CTE sí puede exponer una columna real con uno\nde esos nombres como alias (`phone AS phone_verified`) y la consulta que lee esa CTE lo\nusa: el control es por columna del mart, no por nombre. Para «tiene teléfono»\nusa `phone`; para redes, `instagram` / `tiktok`; para la app, `has_app_installed`.\nTampoco existen en ninguna tabla `end_date` (usa `end_ts`), `venue_*` (usa `country`,\n`locality`, `postal_code`, `admin_area_1..3` de `events`), `total_price_cents` (usa\n`total_price` o `total_price_eur`) ni `nevent_temperature`. Además, un nombre que no es\ncolumna de la tabla que se lee responde el mismo 400 aunque exista en otra (por ejemplo\n`id`, `owned_by` o `created_at` en `purchase_tickets`, `status` o `created_at` en\n`tickets`, `language` en `user_tenants`); `count` no lee su campo y sigue siendo válido\ncon `id`. `users` e `interaction` como colección, `sourceTable` o colección de CTE\nresponden 400 `INVALID_REQUEST`.\nUn campo de la propia colección puede ir cualificado con su tabla\n(`user_tenants.phone` sobre `user_tenants`): es la columna `phone`, y una dimensión\nsin alias se devuelve con la clave `phone`. Con otra tabla solo se acepta lo que el\nJOIN automático ya resuelve para ese campo (dimensión o métrica, sin CTEs ni\ncomparativas): `user_tenants.genre` sobre `tickets` equivale a `genre`. Cualquier otro\ncalificador (campo ambiguo como `events.status` sobre `tickets`, tablas sin relación,\nfiltros, orden, condiciones, CTEs, más de un punto) responde 400 `INVALID_FIELD`\n«Unsupported field qualifier» sin ejecutar nada.\n","value":{"success":false,"error":{"code":"INVALID_FIELD","message":"Analytics query failed","details":"Analytics query failed: Field not available in the analytics mart: phone_verified (query dimension)"},"timestamp":"2026-09-23T13:00:00.000Z"}}}}}},"Unauthorized":{"description":"No autorizado - Token JWT inválido o faltante. Los rechazos del middleware de\nautenticación usan el envelope `AuthError` (`{ success, message, code? }`); un\n`activeTenantId` mal formado devuelve `code: INVALID_ACTIVE_TENANT` (ver\n`components.responses.InvalidActiveTenant`), un token de tipo no admitido\n`code: INVALID_TOKEN_TYPE` con `message: Tipo de token no aceptado`, y un token de\nimpersonación cuya sesión terminó `code: IMPERSONATION_SESSION_TERMINATED`.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"examples":{"missing_token":{"summary":"Token faltante","value":{"success":false,"error":{"code":"UNAUTHORIZED","message":"TenantId not found in request"},"timestamp":"2025-09-27T16:00:00.000Z"}},"invalid_token":{"summary":"Token inválido","value":{"success":false,"error":{"code":"UNAUTHORIZED","message":"Invalid JWT token"},"timestamp":"2025-09-27T16:00:00.000Z"}}}}}},"InvalidActiveTenant":{"description":"Token válido cuyo `activeTenantId` no es un ObjectId utilizable (array, objeto,\nnúmero, espacios…). No se interpreta como \"sin selección\".\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"},"example":{"success":false,"message":"La organización seleccionada no es válida","code":"INVALID_ACTIVE_TENANT"}}}},"DataAccessForbidden":{"description":"Acceso denegado antes de ejecutar ninguna consulta. Dos casos:\n- **Rol no admitido** (`error.code: FORBIDDEN`): las rutas de datos solo admiten tokens\n  con rol ADMIN, OWNER o SUPERADMIN (los `campaign_service_token` de nev-api llevan rol\n  ADMIN). Un token de fan (USER), STAFF, EXTERNAL o sin rol recibe 403.\n- **Selección ajena** (`code: ACTIVE_TENANT_FORBIDDEN`): la organización seleccionada\n  (`activeTenantId`) no pertenece a la jerarquía actual de `tenantId`.\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiError"},{"$ref":"#/components/schemas/AuthError"}]},"examples":{"roleNotAllowed":{"summary":"Rol no admitido","value":{"success":false,"error":{"code":"FORBIDDEN","message":"This endpoint requires ADMIN role"},"timestamp":"2026-09-25T09:00:00.000Z"}},"activeTenantForbidden":{"summary":"Selección ajena","value":{"success":false,"message":"No tienes acceso a la organización seleccionada","code":"ACTIVE_TENANT_FORBIDDEN"}}}}}},"ActiveTenantForbidden":{"description":"La organización seleccionada (`activeTenantId`) no pertenece a la jerarquía actual de\n`tenantId`, o el acceso se retiró después de emitir el token. No se ejecuta ninguna\nconsulta.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"},"example":{"success":false,"message":"No tienes acceso a la organización seleccionada","code":"ACTIVE_TENANT_FORBIDDEN"}}}},"TenantHierarchyUnavailable":{"description":"No se pudo leer la jerarquía de tenants (MongoDB no disponible). Reintentable; nunca\nse sustituye por un ámbito parcial o global.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthError"},"example":{"success":false,"message":"Tenant hierarchy temporarily unavailable, please retry","code":"TENANT_HIERARCHY_UNAVAILABLE"}}}},"InternalServerError":{"description":"Error interno del servidor","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"examples":{"database_error":{"summary":"Error de database","value":{"success":false,"error":{"code":"QUERY_FAILED","message":"Table not found: email_usage_daily"},"timestamp":"2025-09-27T16:00:00.000Z"}},"general_error":{"summary":"Error general","value":{"success":false,"error":{"code":"INTERNAL_ERROR","message":"Analytics query failed: Unknown error"},"timestamp":"2025-09-27T16:00:00.000Z"}}}}}}},"examples":{"EmailUsageDailyQuery":{"summary":"Consulta típica de uso de email","description":"Ejemplo real de consulta para obtener métricas de envío de emails\nagregadas por mes durante un período específico.\n","value":{"collection":"email_usage_daily","dimensions":[{"field":"send_date","alias":"month"}],"metrics":[{"field":"emails_sent","operation":"sum","alias":"total_emails"},{"field":"*","operation":"count","alias":"days_with_sends"}],"timeGranularity":"month","filters":[{"field":"send_date","operator":"between","value":["2024-01-01","2024-12-31"]},{"field":"emails_sent","operator":"gt","value":0}],"sort":{"field":"month","order":"asc"}}},"TierComparisonQuery":{"summary":"Comparación por tiers","description":"Ejemplo de consulta para comparar uso entre diferentes tiers de servicio.\n","value":{"collection":"email_usage_daily","dimensions":[{"field":"tier_name","alias":"tier"}],"metrics":[{"field":"emails_sent","operation":"sum","alias":"total_emails"},{"field":"emails_sent","operation":"avg","alias":"avg_daily_emails"}],"filters":[{"field":"send_date","operator":"gte","value":"2024-01-01"},{"field":"tier_name","operator":"in","value":["Professional","Enterprise"]}],"having":[{"field":"total_emails","operator":"gte","value":1000}],"sort":{"field":"total_emails","order":"desc"}}}}},"tags":[{"name":"Event Sales Provenance","description":"Procedencia de las ventas de un evento (#22): ventas por ticketera, canal de distribución y\norigen de marketing informados por la ticketera o capturados por Nevent, con conciliación,\ncobertura, drill-down y CSV. Importes brutos informados; el neto no se calcula.\n"},{"name":"Segmentation","x-displayName":"User Segmentation API","description":"**API de Segmentación de Usuarios**\n\nSistema completo para crear segmentos de fans basado en criterios dinámicos.\n\n**Características**:\n- **45+ criterios** organizados en 6 categorías\n- **Lógica DSL flexible**: OR dentro de stanzas, AND entre stanzas\n- **Campos personalizados** con tipos dinámicos\n- **Timeframes y modificadores** para segmentación temporal\n- **Preview y ejecución** con paginación optimizada\n\n**Flujo típico**:\n1. `GET /segmentation/criteria` - Obtener criterios disponibles\n2. `POST /dimensions/values` - Obtener valores para un criterio\n3. `POST /segments/preview` - Vista previa rápida del segmento\n4. `POST /segments/execute` - Ejecutar y paginar resultados\n"},{"name":"Custom Fields","x-displayName":"Dynamic Custom Fields","description":"**API de Campos Personalizados**\n\nGestión de custom fields para segmentación dinámica.\n\n**Características**:\n- **Detección automática de tipos**: TEXT, NUMBER, BOOLEAN, DATE\n- **Cache de 5 minutos** para performance\n- **Operadores dinámicos** según tipo de dato\n- **Integración con segmentación** vía criterion_id `user_custom_field`\n\n**Uso**:\n1. `GET /custom-fields/properties` - Listar campos disponibles\n2. `GET /custom-fields/properties/:name` - Ver detalles y valores\n3. Usar en segmentación con `filters.property_name`\n"},{"name":"Import Sync","x-displayName":"Import Readiness","description":"**Sincronización de importaciones**\n\nEndpoints servicio-a-servicio que demuestran que los datos aceptados por una importación\nhan llegado al mart antes de que la segmentación los use.\n\nLa prueba es por usuario y por campo mapeado: se compara la huella canónica del documento\nde origen actual con la que BigQuery calcula sobre la fila del mart. Ni los recuentos ni\nlas ventanas temporales sirven como prueba.\n"},{"name":"Fan Event Activity","x-displayName":"Fan Event Activity","description":"**Actividad por evento del fan** (ticket #59). Lectura interna consumida por la fachada CRM\nde nev-api: histórico completo del fan en su alcance, filas por edición, grupo, evento o\nfuente, detalle de pedidos, cashless y marketing. Solo JWT de usuario.\n"},{"name":"Health Check","x-displayName":"Health & Status","description":"Verificación de estado y conectividad del servicio.\n\nÚtil para monitoreo y verificación de dependencias.\n"},{"name":"Health","x-displayName":"Health Monitoring","description":"Monitoreo de salud y métricas del sistema.\n\nEndpoints para verificar el estado operacional de la API.\n"},{"name":"Filters","x-displayName":"Dynamic Filters","description":"Obtención de valores únicos para construcción de filtros dinámicos.\n\nPermite al frontend conocer qué valores están disponibles para filtrar.\n"},{"name":"Query","x-displayName":"Analytics Query Engine","description":"Ejecución de consultas analíticas flexibles.\n\nEl corazón de la API - permite definir cualquier tipo de consulta analítica.\n"},{"name":"Schema Discovery","x-displayName":"Schema Introspection","description":"Introspección segura de tablas y esquemas de base de datos.\n\nPermite conocer la estructura de datos disponible (Admin only).\n"},{"name":"Analytics Capabilities","x-displayName":"Analytics Capabilities","description":"Descubrimiento de capacidades analíticas disponibles.\n\nLista colecciones, métricas y dimensiones soportadas.\n"}],"externalDocs":{"description":"Documentación completa de Nevent Data API","url":"/docs"}}