{
  "openapi": "3.1.0",
  "info": {
    "title": "Projekt Republic API",
    "version": "1.0.0",
    "description": "API v1 del monorepo **Projekt**.\n\n- Prefijo de negocio: `/api/v1`. Probes `/health` y `/ready` SIN prefijo.\n- Autenticación por cookies **HttpOnly**: `access_token` (JWT, 15 min) +\n  `refresh_token` (opaco, rotativo, hasheado en DB). `SameSite=Lax`,\n  `Secure` según `COOKIE_SECURE`.\n- Errores SIEMPRE con envelope `{\"error\":{\"code\",\"message\",\"request_id?\"}}`.\n",
    "license": {
      "name": "Proprietary — 3XA Inc",
      "url": "https://3xa.es"
    },
    "contact": {
      "name": "3XA Inc",
      "email": "claude@3xa.es"
    }
  },
  "servers": [
    {
      "url": "http://localhost:8000",
      "description": "Desarrollo local (uvicorn / docker compose)"
    }
  ],
  "security": [
    {
      "cookieAuth": []
    }
  ],
  "tags": [
    {
      "name": "health",
      "description": "Probes de proceso e infraestructura (sin prefijo /api/v1)"
    },
    {
      "name": "metering",
      "description": "Consumo medido: qué se ha gastado de cada recurso y de cuánto se dispone. Genérico por recurso y no «de Kern», para que el cupo de vídeo no pida escribir los mismos endpoints otra vez."
    },
    {
      "name": "meetings",
      "description": "Reuniones/llamadas de vídeo (LiveKit) dentro de una organización"
    },
    {
      "name": "auth",
      "description": "Registro, sesión por cookies y perfil propio"
    },
    {
      "name": "organizations",
      "description": "Organizaciones del usuario autenticado"
    },
    {
      "name": "billing",
      "description": "Plan freemium, entitlements y pagos/suscripción (Stripe) de una organización"
    },
    {
      "name": "projects",
      "description": "Proyectos dentro de una organización"
    },
    {
      "name": "tasks",
      "description": "Tareas dentro de un proyecto"
    },
    {
      "name": "sprints",
      "description": "Sprints (iteraciones ágiles) de un proyecto y su burndown"
    },
    {
      "name": "task-comments",
      "description": "Comentarios (con hilos) de una tarea"
    },
    {
      "name": "tags",
      "description": "Etiquetas (labels) de una organización"
    },
    {
      "name": "finance",
      "description": "Facturas, gastos y resumen financiero de una organización"
    },
    {
      "name": "clients",
      "description": "Clientes (CRM) de una organización"
    },
    {
      "name": "suppliers",
      "description": "Proveedores de una organización (con dashboard de gasto agregado)"
    },
    {
      "name": "crm",
      "description": "CRM (pipelines, deals, leads, actividades y analítica) de una organización"
    },
    {
      "name": "crossorg",
      "description": "Compartición de proyectos entre organizaciones (enlaces B2B + comparticiones + acceso cross-tenant controlado de solo lectura)"
    },
    {
      "name": "departments",
      "description": "Departamentos de RRHH de una organización (horarios, vacaciones y festivos)"
    },
    {
      "name": "employees",
      "description": "Directorio de empleados (RRHH) de una organización"
    },
    {
      "name": "time-entries",
      "description": "Registros de tiempo (horas imputadas) de una organización"
    },
    {
      "name": "jornada",
      "description": "Registro de jornada (el registro horario de la ley española): asientos inalterables de entrada, salida y pausas, encadenados por huella"
    },
    {
      "name": "verifactu",
      "description": "Registro de facturación Veri*Factu (RD 1007/2023): asientos encadenados por huella del alta y la anulación de cada factura"
    },
    {
      "name": "documents",
      "description": "Documentos (notas/markdown, árbol + versiones) de una organización"
    },
    {
      "name": "document-comments",
      "description": "Comentarios (con hilos) de un documento"
    },
    {
      "name": "dashboard",
      "description": "Panel de inicio (agregados y actividad) de una organización"
    },
    {
      "name": "api-keys",
      "description": "API keys (Personal Access Tokens) de una organización"
    },
    {
      "name": "vault",
      "description": "Vault de credenciales compartidas de una organización (suscripciones de software, cuentas de servicio): secreto cifrado en reposo, listados sin secreto y revelado explícito auditado"
    },
    {
      "name": "notifications",
      "description": "Notificaciones in-app del usuario autenticado dentro de una organización (campana del topbar): listar, contar sin leer y marcar leídas"
    },
    {
      "name": "hr",
      "description": "Gestión de RRHH: solicitudes de ausencia (leave requests), balances de vacaciones, calendario de ausencias y timesheets semanales con flujo de aprobación"
    },
    {
      "name": "task-dependencies",
      "description": "Dependencias entre tareas del mismo proyecto (blocks/blocked_by/relates_to)"
    },
    {
      "name": "sla",
      "description": "Motor de SLA: políticas de compromiso de servicio (primera respuesta, resolución) medidas en horas laborables, y el reloj de cada tarea"
    },
    {
      "name": "support",
      "description": "Mesa de soporte (service desk): tipos de petición con sus campos, colas y peticiones con solicitante y canal de entrada"
    },
    {
      "name": "portal",
      "description": "Portal de cliente: identidad de la persona (código de un solo uso a un contacto registrado) y sus peticiones de soporte con conversación"
    },
    {
      "name": "saved-filters",
      "description": "Filtros de tareas guardados por el usuario en una organización"
    },
    {
      "name": "desktop-state",
      "description": "El escritorio de cada persona (ventanas, escritorios, orden del dock) guardado en el servidor, con versión y dispositivo para que dos sitios abiertos a la vez no se pisen en silencio"
    },
    {
      "name": "reminders",
      "description": "Recordatorios personales dentro de una organización: texto, cuándo avisa, si está hecho y, opcionalmente, la tarea o el cliente del que cuelgan"
    },
    {
      "name": "notes",
      "description": "Notas personales dentro de una organización: cuerpo en markdown, título derivado de su primera línea, fijar arriba y archivar. Privadas de su persona, como los recordatorios"
    },
    {
      "name": "board-columns",
      "description": "Columnas configurables del tablero Kanban por proyecto"
    },
    {
      "name": "search",
      "description": "Búsqueda global (⌘K) dentro de una organización"
    },
    {
      "name": "calendar",
      "description": "Calendario: feed ICS personal (tareas, reuniones y ausencias del usuario) + festivos de la organización y eventos de calendario (festivos + ausencias) por rango"
    },
    {
      "name": "payroll",
      "description": "Nóminas v2 (gross→net español): corridas, payslips y horarios semanales de empleados"
    },
    {
      "name": "workforce",
      "description": "Conciliación de CAPACIDAD por semana: horas esperadas (modelo híbrido) vs imputadas vs ausencias, por miembro"
    },
    {
      "name": "roadmap",
      "description": "Roadmap / Gantt y Earned Value Management (Wave H1): vista temporal de proyectos y tareas + métricas EVM en horas de esfuerzo"
    },
    {
      "name": "contracts",
      "description": "Contratos org-scoped (Wave H2): contracts con numeración CT-YYYY-NNNN, plantillas reutilizables con {{variables}} e hitos de pago. Sin firma electrónica (fuera de alcance)."
    },
    {
      "name": "bi",
      "description": "BI builder Metabase-lite (Wave H2): dashboards y widgets con motor de consulta whitelist-driven (injection-safe). Fuentes: tasks, invoices, expenses, projects, clients. Dimensiones y medidas validadas contra registry estático — strings de usuario nunca se interpolan en SQL."
    },
    {
      "name": "automations",
      "description": "Motor de automatizaciones org-scoped (Wave I1): reglas trigger→condiciones→acción sobre el ciclo de vida de tareas. Tres triggers: task_created, task_status_changed, task_assigned. Cinco acciones: set_priority, set_status, assign, add_comment, notify. Loop-suppression integrado. Ver requiere member+; mutar admin+."
    },
    {
      "name": "holdings",
      "description": "Holdings (grupo empresarial: organización matriz + filiales) y panel CONSOLIDADO de SOLO LECTURA. Superficie separada `/holdings/…`"
    },
    {
      "name": "agency",
      "description": "Resumen multi-cliente (Wave I2): roll-up por cliente con proyectos (contratos), tareas abiertas y totales financieros. Solo lectura, member+."
    },
    {
      "name": "places",
      "description": "Proxy de Google Places (New) para autocompletar direcciones y obtener detalles estructurados. La API key de Google se guarda en el servidor y NUNCA se expone al navegador. Requiere GOOGLE_MAPS_API_KEY en el entorno."
    },
    {
      "name": "github",
      "description": "Integración GitHub org-scoped: token PAT cifrado por organización, vínculos proyecto↔repositorio y actividad de desarrollo (commits, PRs open, PRs mergeados) con cache in-memory de 60 s. Configurar requiere admin+; consultar requiere member+."
    },
    {
      "name": "attachments",
      "description": "Adjuntos genéricos (PNG/JPG/WEBP/GIF/PDF, máx. 10 MB). Tabla polimórfica: un solo endpoint sirve facturas, gastos y tareas. Los bytes viven en StorageService y se descargan por endpoint autenticado (PJKT-2016); las filas anteriores a esa fecha se sirven todavía desde /uploads/attachments/ gateado por nginx. Validación IDOR por org en los dos caminos."
    },
    {
      "name": "audit",
      "description": "Audit log self-service de una organización (Wave 8): historial de acciones propias de la organización (miembros, proyectos, tareas, api-keys…) para owner/admin, paginado y filtrable por acción/actor/rango de fechas. SIEMPRE filtrado por `organization_id`; excluye siempre las acciones internas `platform.*` del panel OPS (impersonation, purga, suspensión, plan, feature flags…), que no son actividad propia del cliente. Ver también el tag `platform` para el audit log GLOBAL cross-tenant (solo platform admins)."
    },
    {
      "name": "platform",
      "description": "Panel de operaciones de plataforma (ops.projekt.3xa.es): listados globales cross-tenant de organizaciones, usuarios, proveedores, errores capturados y audit log, más gestión de la allowlist de platform admins. Doble gate: sesión por cookie (los Bearer PAT/OAuth se rechazan) + email de dominio sesión interactiva de navegador + fila en `platform_admins` (la allowlist es nominal: vale cualquier email). Todo lo demás → 403 `forbidden`."
    },
    {
      "name": "oauth-as",
      "description": "OAuth 2.1 Authorization Server para el MCP remoto (`/mcp`) y clientes de terceros: discovery RFC 8414/9728, registro dinámico RFC 7591, authorization_code + PKCE S256 y refresh tokens rotativos. Los access tokens emitidos (`pjk_oat_…`) valen como Bearer en toda la API v1 con el alcance del usuario que consintió."
    },
    {
      "name": "okr",
      "description": "Objetivos (OKR) y sus resultados clave (KPI) por organización. Gestión manager+ desde el hub Organización; el progreso se deriva de current_value/target_value."
    },
    {
      "name": "store",
      "description": "La Store de apps: catálogo público (modelo free/freemium/paid/addon, precios y reparto gratis/premium de cada capacidad) e instalación y desinstalación por organización. Instalar es gratis siempre; el pago por app llega con las suscripciones"
    }
  ],
  "paths": {
    "/health": {
      "get": {
        "operationId": "getHealth",
        "tags": [
          "health"
        ],
        "summary": "Liveness — proceso vivo",
        "description": "Devuelve 200 si el proceso atiende peticiones. No toca DB ni Redis.",
        "security": [],
        "responses": {
          "200": {
            "description": "Proceso vivo.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "status"
                  ],
                  "properties": {
                    "status": {
                      "type": "string",
                      "examples": [
                        "ok"
                      ]
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/ready": {
      "get": {
        "operationId": "getReady",
        "tags": [
          "health"
        ],
        "summary": "Readiness — DB y Redis OK",
        "description": "Devuelve 200 solo si MySQL y Redis responden; 503 en caso contrario.",
        "security": [],
        "responses": {
          "200": {
            "description": "Dependencias OK (DB + Redis).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "status"
                  ],
                  "properties": {
                    "status": {
                      "type": "string",
                      "examples": [
                        "ok"
                      ]
                    }
                  }
                }
              }
            }
          },
          "503": {
            "description": "DB o Redis no disponibles.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/version": {
      "get": {
        "operationId": "getVersion",
        "tags": [
          "health"
        ],
        "summary": "Metadata de build — versión, commit y entorno",
        "description": "Endpoint público de infraestructura para diagnóstico de despliegue. Nunca incluye secretos, solo metadata de build fijada en build/deploy time (o \"unknown\" si no se configuró).",
        "security": [],
        "responses": {
          "200": {
            "description": "Metadata de build.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "app_version",
                    "git_sha",
                    "build_time",
                    "app_env"
                  ],
                  "properties": {
                    "app_version": {
                      "type": "string",
                      "examples": [
                        "1.4.2"
                      ]
                    },
                    "git_sha": {
                      "type": "string",
                      "examples": [
                        "a1b2c3d"
                      ]
                    },
                    "build_time": {
                      "type": "string",
                      "examples": [
                        "2026-07-24T10:00:00Z"
                      ]
                    },
                    "app_env": {
                      "type": "string",
                      "examples": [
                        "production"
                      ]
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/.well-known/oauth-authorization-server": {
      "get": {
        "operationId": "getOauthAsMetadata",
        "tags": [
          "oauth-as"
        ],
        "summary": "Metadata del Authorization Server (RFC 8414)",
        "description": "Documento de descubrimiento del AS. Los clientes MCP lo resuelven desde el `WWW-Authenticate` del resource server para iniciar el flujo OAuth.",
        "security": [],
        "responses": {
          "200": {
            "description": "Metadata del AS.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "issuer",
                    "authorization_endpoint",
                    "token_endpoint",
                    "registration_endpoint",
                    "response_types_supported",
                    "grant_types_supported",
                    "code_challenge_methods_supported",
                    "token_endpoint_auth_methods_supported"
                  ],
                  "properties": {
                    "issuer": {
                      "type": "string",
                      "examples": [
                        "https://projekt.3xa.es"
                      ]
                    },
                    "authorization_endpoint": {
                      "type": "string",
                      "examples": [
                        "https://projekt.3xa.es/api/v1/oauth/authorize"
                      ]
                    },
                    "token_endpoint": {
                      "type": "string",
                      "examples": [
                        "https://projekt.3xa.es/api/v1/oauth/token"
                      ]
                    },
                    "registration_endpoint": {
                      "type": "string",
                      "examples": [
                        "https://projekt.3xa.es/api/v1/oauth/register"
                      ]
                    },
                    "response_types_supported": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "examples": [
                        [
                          "code"
                        ]
                      ]
                    },
                    "grant_types_supported": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "examples": [
                        [
                          "authorization_code",
                          "refresh_token"
                        ]
                      ]
                    },
                    "code_challenge_methods_supported": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "examples": [
                        [
                          "S256"
                        ]
                      ]
                    },
                    "token_endpoint_auth_methods_supported": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "examples": [
                        [
                          "none"
                        ]
                      ]
                    },
                    "scopes_supported": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "examples": [
                        [
                          "projekt"
                        ]
                      ]
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/.well-known/oauth-protected-resource": {
      "get": {
        "operationId": "getOauthProtectedResourceMetadata",
        "tags": [
          "oauth-as"
        ],
        "summary": "Metadata del Protected Resource (RFC 9728)",
        "description": "Describe el resource server MCP (`/mcp`) y qué AS lo protege. Servido también bajo `/.well-known/oauth-protected-resource/mcp` (mismo shape), que es la ruta que derivan los clientes MCP desde la URL del recurso.",
        "security": [],
        "responses": {
          "200": {
            "description": "Metadata del protected resource.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "resource",
                    "authorization_servers"
                  ],
                  "properties": {
                    "resource": {
                      "type": "string",
                      "examples": [
                        "https://projekt.3xa.es/mcp"
                      ]
                    },
                    "authorization_servers": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "examples": [
                        [
                          "https://projekt.3xa.es"
                        ]
                      ]
                    },
                    "scopes_supported": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "examples": [
                        [
                          "projekt"
                        ]
                      ]
                    },
                    "bearer_methods_supported": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "examples": [
                        [
                          "header"
                        ]
                      ]
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/.well-known/oauth-protected-resource/mcp": {
      "get": {
        "operationId": "getOauthProtectedResourceMetadataMcp",
        "tags": [
          "oauth-as"
        ],
        "summary": "Metadata del Protected Resource — ruta derivada de /mcp (RFC 9728)",
        "description": "Mismo documento que `/.well-known/oauth-protected-resource`; los clientes MCP derivan esta ruta insertando el path del recurso (`/mcp`) tras el well-known.",
        "security": [],
        "responses": {
          "200": {
            "description": "Metadata del protected resource.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "resource",
                    "authorization_servers"
                  ],
                  "properties": {
                    "resource": {
                      "type": "string"
                    },
                    "authorization_servers": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "scopes_supported": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "bearer_methods_supported": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/oauth/register": {
      "post": {
        "operationId": "registerOauthClient",
        "tags": [
          "oauth-as"
        ],
        "summary": "Registro dinámico de cliente (DCR)",
        "description": "Alta anónima de un cliente PÚBLICO (sin secret; `token_endpoint_auth_method` = `none`). `redirect_uris` debe ser https (se permite `http://localhost` y `http://127.0.0.1` para desarrollo). Respuesta y errores en formato RFC 7591 (sin envelope).\n\nLos tres campos de configuración (`token_endpoint_auth_method`, `grant_types`, `response_types`) se VALIDAN: pedir algo que este AS no soporta es `400 invalid_client_metadata`, nunca un alta que acepte la petición y aplique otra cosa. Pedir MENOS sí vale —omitirlos es lo normal— y entonces manda la metadata de la respuesta (RFC 7591 §3.2.1), que es la configuración que el AS va a aplicar de verdad.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "redirect_uris"
                ],
                "properties": {
                  "redirect_uris": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 10,
                    "items": {
                      "type": "string"
                    },
                    "description": "URIs exactas de callback (comparación literal en authorize)."
                  },
                  "client_name": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Nombre mostrado en la pantalla de consentimiento."
                  },
                  "token_endpoint_auth_method": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "enum": [
                      "none",
                      null
                    ],
                    "description": "Solo clientes públicos. Cualquier otro método (incluido el `client_secret_basic` que RFC 7591 asume por defecto) se rechaza con `400 invalid_client_metadata`: este AS no emite secretos y no va a registrar como público a quien se cree confidencial."
                  },
                  "grant_types": {
                    "type": [
                      "array",
                      "null"
                    ],
                    "items": {
                      "type": "string",
                      "enum": [
                        "authorization_code",
                        "refresh_token"
                      ]
                    },
                    "description": "Grants soportados. Un grant fuera de la lista es `400 invalid_client_metadata`. Pedir solo `authorization_code` (el defecto del RFC al omitirlo) no acota nada: la respuesta devuelve los dos, que es lo que el AS aplica."
                  },
                  "response_types": {
                    "type": [
                      "array",
                      "null"
                    ],
                    "items": {
                      "type": "string",
                      "enum": [
                        "code"
                      ]
                    },
                    "description": "Sin flujo implícito: `token`/`id_token` son `400 invalid_client_metadata`."
                  },
                  "scope": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Scopes solicitados separados por espacio. Solo existe `projekt`; pedir otro NO rompe el registro (la respuesta manda, RFC 7591 §3.2.1) pero `/oauth/authorize` responde `invalid_scope` a quien insista con uno distinto."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Cliente registrado. Esta metadata es la AUTORITATIVA (RFC 7591 §3.2.1): es la configuración que el AS aplica, aunque el cliente pidiera menos.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "client_id",
                    "client_id_issued_at",
                    "client_name",
                    "redirect_uris",
                    "token_endpoint_auth_method",
                    "grant_types",
                    "response_types",
                    "scope"
                  ],
                  "properties": {
                    "client_id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "client_id_issued_at": {
                      "type": "integer",
                      "description": "Epoch seconds."
                    },
                    "client_name": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "redirect_uris": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "token_endpoint_auth_method": {
                      "type": "string"
                    },
                    "grant_types": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "response_types": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "scope": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Metadata inválida (`invalid_client_metadata` / `invalid_redirect_uri`, formato RFC 7591).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "error_description": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/oauth/authorize": {
      "get": {
        "operationId": "oauthAuthorize",
        "tags": [
          "oauth-as"
        ],
        "summary": "Autorización (redirect a login/consent de la web)",
        "description": "Punto de entrada del flujo authorization_code+PKCE. Valida cliente, `redirect_uri` (match exacto) y `code_challenge` S256; crea una petición de autorización efímera y redirige (302) a la pantalla de consentimiento de la web (`{WEB}/oauth/consent?rid=…`), que exige sesión. Errores de cliente/redirect no redirigen (400 JSON); el resto vuelve al `redirect_uri` con `?error=`.",
        "security": [],
        "parameters": [
          {
            "name": "response_type",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "code"
              ]
            }
          },
          {
            "name": "client_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "redirect_uri",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "code_challenge",
            "in": "query",
            "required": true,
            "description": "SHA-256 del code_verifier en base64url (RFC 7636).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "code_challenge_method",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "S256"
              ]
            }
          },
          {
            "name": "scope",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "state",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "resource",
            "in": "query",
            "required": false,
            "description": "Resource indicator (RFC 8707); informativo.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "302": {
            "description": "Redirección a la pantalla de consentimiento (o al redirect_uri con `?error=`).",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "description": "Cliente o redirect_uri inválidos (sin redirección, formato RFC 6749).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "error_description": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/oauth/consent/{request_id}": {
      "parameters": [
        {
          "name": "request_id",
          "in": "path",
          "required": true,
          "description": "Id de la petición de autorización pendiente (query `rid` del redirect).",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getOauthConsentInfo",
        "tags": [
          "oauth-as"
        ],
        "summary": "Datos para la pantalla de consentimiento",
        "description": "Qué cliente pide acceso y con qué alcance. Requiere sesión (cookie).",
        "responses": {
          "200": {
            "description": "Información de la petición pendiente.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "title": "OauthConsentInfo",
                  "required": [
                    "request_id",
                    "client_name",
                    "redirect_host",
                    "scope",
                    "expires_at"
                  ],
                  "properties": {
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "client_name": {
                      "type": "string",
                      "description": "Nombre registrado del cliente (o su host si no dio nombre)."
                    },
                    "redirect_host": {
                      "type": "string",
                      "description": "Host del redirect_uri (para mostrar al usuario)."
                    },
                    "scope": {
                      "type": "string"
                    },
                    "expires_at": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Petición inexistente, caducada o ya resuelta (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "decideOauthConsent",
        "tags": [
          "oauth-as"
        ],
        "summary": "Aprobar o denegar la petición",
        "description": "Resuelve la petición pendiente. Si `approve=true` emite un authorization code de un solo uso (10 min) ligado al usuario de la sesión; si no, construye el redirect con `error=access_denied`. La página hace `window.location = redirect_to`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "approve"
                ],
                "properties": {
                  "approve": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "URL a la que volver (con `code` o con `error=access_denied`).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "title": "OauthConsentDecision",
                  "required": [
                    "redirect_to"
                  ],
                  "properties": {
                    "redirect_to": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Petición inexistente, caducada o ya resuelta (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/oauth/token": {
      "post": {
        "operationId": "oauthToken",
        "tags": [
          "oauth-as"
        ],
        "summary": "Canje de code / rotación de refresh token",
        "description": "`grant_type=authorization_code` (+ `code`, `redirect_uri`, `client_id`, `code_verifier` PKCE) o `grant_type=refresh_token` (+ `refresh_token`, `client_id`). Los refresh tokens ROTAN en cada uso; reutilizar uno ya rotado revoca toda la familia (detección de robo). Access token 1 h, refresh 30 días. Errores en formato RFC 6749.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "required": [
                  "grant_type"
                ],
                "properties": {
                  "grant_type": {
                    "type": "string",
                    "enum": [
                      "authorization_code",
                      "refresh_token"
                    ]
                  },
                  "code": {
                    "type": "string"
                  },
                  "redirect_uri": {
                    "type": "string"
                  },
                  "client_id": {
                    "type": "string"
                  },
                  "code_verifier": {
                    "type": "string"
                  },
                  "refresh_token": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Par de tokens emitido.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "title": "OauthTokenResponse",
                  "required": [
                    "access_token",
                    "token_type",
                    "expires_in",
                    "refresh_token",
                    "scope"
                  ],
                  "properties": {
                    "access_token": {
                      "type": "string",
                      "description": "Bearer opaco `pjk_oat_…`, válido en toda la API v1."
                    },
                    "token_type": {
                      "type": "string",
                      "enum": [
                        "Bearer"
                      ]
                    },
                    "expires_in": {
                      "type": "integer",
                      "description": "Segundos de vida del access token."
                    },
                    "refresh_token": {
                      "type": "string",
                      "description": "Opaco `pjk_ort_…`; se ROTA en cada refresh."
                    },
                    "scope": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "`invalid_grant` (code/refresh inválido, caducado o reutilizado), `invalid_request` o `unsupported_grant_type` (formato RFC 6749).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "error_description": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Cliente desconocido (`invalid_client`, formato RFC 6749).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "error_description": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/oauth/authorizations": {
      "get": {
        "operationId": "listMyOauthAuthorizations",
        "tags": [
          "oauth-as"
        ],
        "summary": "Listar mis apps conectadas (autorizaciones OAuth activas)",
        "description": "Autorizaciones OAuth ACTIVAS del usuario autenticado: qué clientes/apps tienen acceso vivo a su cuenta a través del AS (una familia de tokens con al menos un refresh NO revocado y NO caducado). SIEMPRE solo las del propio usuario de la sesión — nunca las de otro. Cada fila es una autorización (un consentimiento aprobado → una familia de tokens); su `id` es lo que se pasa a `DELETE` para revocarla.",
        "responses": {
          "200": {
            "description": "Autorizaciones activas del usuario (puede ser una lista vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "title": "OauthAuthorization",
                    "required": [
                      "id",
                      "client_id",
                      "client_name",
                      "scope",
                      "authorized_at",
                      "last_used_at"
                    ],
                    "properties": {
                      "id": {
                        "type": "string",
                        "format": "uuid",
                        "description": "Id de la autorización (familia de tokens del grant). Es el identificador que se pasa a `DELETE` para revocarla."
                      },
                      "client_id": {
                        "type": "string",
                        "format": "uuid",
                        "description": "Cliente OAuth (app) al que se concedió el acceso."
                      },
                      "client_name": {
                        "type": [
                          "string",
                          "null"
                        ],
                        "description": "Nombre registrado del cliente (o `null` si no dio uno)."
                      },
                      "scope": {
                        "type": "string",
                        "description": "Alcance concedido (hoy siempre `projekt`)."
                      },
                      "authorized_at": {
                        "type": "string",
                        "format": "date-time",
                        "description": "Cuándo se aprobó el consentimiento original."
                      },
                      "last_used_at": {
                        "type": [
                          "string",
                          "null"
                        ],
                        "format": "date-time",
                        "description": "Último uso de un token vivo de la familia, o `null` si aún no se ha usado."
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/oauth/authorizations/{authorization_id}": {
      "parameters": [
        {
          "name": "authorization_id",
          "in": "path",
          "required": true,
          "description": "Id de la autorización a revocar (el `id` devuelto por el listado).",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "revokeMyOauthAuthorization",
        "tags": [
          "oauth-as"
        ],
        "summary": "Revocar una app conectada (autorización OAuth)",
        "description": "Revoca una autorización PROPIA: marca revocados TODOS sus tokens vivos (access y refresh) de esa familia, de modo que dejan de autenticar de inmediato (el access → 401 en la siguiente petición; el refresh → sin rotación posible). La pertenencia se comprueba en el servidor: si la autorización no existe o es de OTRO usuario, responde `404` opaco (nunca se revoca por un id sin validar el dueño). Idempotente.",
        "responses": {
          "204": {
            "description": "Autorización revocada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Autorización inexistente o de otro usuario (`not_found`, opaco: no revela si existe).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/register": {
      "post": {
        "operationId": "registerUser",
        "tags": [
          "auth"
        ],
        "summary": "Registro de usuario",
        "description": "Crea el usuario y abre sesión (set cookies `access_token` + `refresh_token`).",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email",
                  "password",
                  "name"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "password": {
                    "type": "string",
                    "format": "password"
                  },
                  "name": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Usuario creado; cookies de sesión emitidas.",
            "headers": {
              "Set-Cookie": {
                "description": "`access_token` (JWT 15 min) y `refresh_token` (opaco rotativo, hasheado en DB); HttpOnly, SameSite=Lax, Secure según COOKIE_SECURE.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          },
          "409": {
            "description": "Email ya registrado (`email_taken`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas altas (`too_many_requests`): 5/min por IP y 3/min por email. Ventana fija de 60 s; fail-open si Redis no responde.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/login": {
      "post": {
        "operationId": "loginUser",
        "tags": [
          "auth"
        ],
        "summary": "Login",
        "description": "Verifica credenciales y abre sesión (set cookies `access_token` + `refresh_token`).",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email",
                  "password"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "password": {
                    "type": "string",
                    "format": "password"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Sesión abierta; cookies emitidas.",
            "headers": {
              "Set-Cookie": {
                "description": "`access_token` (JWT 15 min) y `refresh_token` (opaco rotativo); HttpOnly, SameSite=Lax, Secure según COOKIE_SECURE.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          },
          "401": {
            "description": "Credenciales inválidas (`invalid_credentials`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiados intentos: `too_many_requests` por rate limit (10/min por IP, 5/min por email; ventana fija de 60 s, fail-open si Redis no responde) o `account_locked` cuando el intento fallido cruza el tope anti-fuerza-bruta de la cuenta.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/refresh": {
      "post": {
        "operationId": "refreshSession",
        "tags": [
          "auth"
        ],
        "summary": "Refresh de sesión",
        "description": "Usa la cookie HttpOnly `refresh_token` (no la de acceso) para emitir un nuevo `access_token` y ROTAR el `refresh_token` (el anterior queda invalidado en DB).",
        "security": [],
        "responses": {
          "200": {
            "description": "Sesión renovada; cookies rotadas.",
            "headers": {
              "Set-Cookie": {
                "description": "Nuevos `access_token` y `refresh_token` (rotación completa).",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          },
          "401": {
            "description": "Refresh token ausente, inválido, caducado o ya rotado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiados refrescos (`too_many_requests`): 30/min por IP, ventana fija de 60 s; fail-open si Redis no responde.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/logout": {
      "post": {
        "operationId": "logoutUser",
        "tags": [
          "auth"
        ],
        "summary": "Logout",
        "description": "Revoca el refresh token en DB y limpia ambas cookies. Idempotente.",
        "security": [],
        "responses": {
          "204": {
            "description": "Sesión cerrada; cookies limpiadas.",
            "headers": {
              "Set-Cookie": {
                "description": "`access_token` y `refresh_token` vaciadas (Max-Age=0).",
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/me": {
      "get": {
        "operationId": "getCurrentUser",
        "x-tool": [
          {
            "name": "whoami",
            "domain": "org",
            "profile": "core",
            "sensitive": false
          },
          {
            "name": "get_context",
            "domain": "org",
            "profile": "core",
            "sensitive": false
          }
        ],
        "tags": [
          "auth"
        ],
        "summary": "Usuario actual",
        "description": "Devuelve el usuario autenticado por la cookie `access_token` o por un PAT Bearer (`Authorization: Bearer pjk_live_…`). Cuando la petición se autentica con un PAT, el campo `api_key` del `User` devuelto incluye el scope de la key (id, nombre, organización y proyecto), lo que permite a los API clients (MCP, scripts, CLI) descubrir el `organization_id` y el `project_id` asociados a la key con la que operan. En sesiones por cookie, `api_key` es `null`.",
        "responses": {
          "200": {
            "description": "Usuario autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado (cookie ausente, inválida o caducada; PAT inválido o revocado).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/impersonation/activate": {
      "get": {
        "operationId": "authImpersonationActivate",
        "tags": [
          "auth"
        ],
        "summary": "Activar una sesión de impersonation (fija cookie y redirige)",
        "description": "Canjea el token OPACO de impersonation por la cookie HttpOnly y redirige (302) a `{WEB_URL}/dashboard`. PÚBLICO: el token ES la credencial. El panel ops abre este enlace en una navegación top-level para que la cookie se fije en el dominio de la web. Token inválido/caducado/revocado → 302 a `{WEB_URL}/login?error=impersonation_invalid` (sin cookie, sin filtrar detalle). GET a propósito: solo emite una cookie (no muta negocio), así que pasa el cerrojo read-only del middleware.",
        "security": [],
        "parameters": [
          {
            "name": "token",
            "in": "query",
            "required": true,
            "description": "Token opaco de la sesión de impersonation (prefijo `pjk_imp_`).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "302": {
            "description": "Cookie de impersonation fijada → `/dashboard`, o `/login?error=impersonation_invalid` si el token no es válido.",
            "headers": {
              "Set-Cookie": {
                "description": "`impersonation` HttpOnly (solo en el caso de éxito).",
                "schema": {
                  "type": "string"
                }
              },
              "Location": {
                "description": "`{WEB_URL}/dashboard` en éxito, o `{WEB_URL}/login?error=…` si falla.",
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/impersonation/exit": {
      "get": {
        "operationId": "authImpersonationExit",
        "tags": [
          "auth"
        ],
        "summary": "Salir de la impersonation (revoca y borra la cookie)",
        "description": "\"Salir\" del banner de impersonation: revoca la sesión (si sigue viva) y BORRA la cookie HttpOnly de impersonation, luego redirige (302) a `{WEB_URL}/login`. PÚBLICO e idempotente. GET a propósito (el banner navega aquí; una escritura la bloquearía el cerrojo read-only). Auditado `platform.impersonation_ended` (via=exit).",
        "security": [],
        "responses": {
          "302": {
            "description": "Cookie de impersonation borrada → `{WEB_URL}/login`.",
            "headers": {
              "Set-Cookie": {
                "description": "Borra la cookie `impersonation`.",
                "schema": {
                  "type": "string"
                }
              },
              "Location": {
                "description": "`{WEB_URL}/login`.",
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/email-change/confirm": {
      "get": {
        "operationId": "authEmailChangeConfirm",
        "tags": [
          "auth"
        ],
        "summary": "Confirmar un cambio de email (enlace del correo de verificación)",
        "description": "Canjea el token firmado (HMAC con APP_SECRET, TTL 24h) que viajó en el correo de verificación enviado al email NUEVO. PÚBLICO: el token ES la credencial (quien lo tiene demostró controlar el buzón nuevo). En éxito aplica el cambio de email, audita `auth.email_changed`, avisa al email VIEJO (\"tu email ha cambiado\", anti account-takeover) y redirige (302) a `{WEB_URL}/login?email_changed=1`. Reconfirmar el mismo enlace es idempotente (redirige sin re-aplicar nada). Token manipulado o caducado → 400 `invalid_token` (envelope); email nuevo ocupado por OTRO usuario desde que se pidió → 409 `email_in_use` (envelope). GET a propósito: es un enlace de correo (navegación top-level).",
        "security": [],
        "parameters": [
          {
            "name": "token",
            "in": "query",
            "required": true,
            "description": "Token firmado de cambio de email (payload user_id + new_email).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "302": {
            "description": "Email cambiado (o ya cambiado) → `{WEB_URL}/login?email_changed=1`.",
            "headers": {
              "Location": {
                "description": "`{WEB_URL}/login?email_changed=1`.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "description": "Token manipulado, malformado o caducado (`invalid_token`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "El email nuevo ya está en uso por otro usuario (`email_in_use`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/me/profile": {
      "patch": {
        "operationId": "updateMyProfile",
        "tags": [
          "auth"
        ],
        "summary": "Editar mi perfil",
        "description": "El usuario autenticado edita SU PROPIO perfil (nunca el de otro: el sujeto es siempre la sesión). Campos parciales; se ignora lo no enviado. NO permite cambiar email ni contraseña. Devuelve el `User` actualizado. Longitudes máximas: `name` 255, `headline` 160, `location` 120, `bio` 500, `avatar_url` 512. Enviar `null` en `headline`/`bio`/`location`/`avatar_url`/`weather_override`/`last_organization_id` los borra. `last_organization_id`, si es non-null, debe ser una organización de la que el usuario es MIEMBRO; en caso contrario responde `404`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "Todos los campos son opcionales; se aplican solo los presentes.",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Nombre visible (no puede quedar vacío si se envía).",
                    "minLength": 1,
                    "maxLength": 255
                  },
                  "headline": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Titular/rol; `null` lo borra.",
                    "maxLength": 160
                  },
                  "bio": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Biografía; `null` la borra.",
                    "maxLength": 500
                  },
                  "location": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Ubicación; `null` la borra.",
                    "maxLength": 120
                  },
                  "avatar_url": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "URL del avatar; `null` lo borra.",
                    "maxLength": 512
                  },
                  "language": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "maxLength": 10,
                    "description": "Idioma preferido en BCP-47 (`es`, `en-US`); se normaliza (`en_us` → `en-US`) y una forma inválida responde 422. `null` lo borra, es decir vuelve a heredar el de la organización (`Organization.locale`) — que es el estado por defecto, no «castellano».",
                    "examples": [
                      "en-US"
                    ]
                  },
                  "weather_override": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Pin de condición meteorológica manual para el widget del dashboard. `null` = live (Open-Meteo). Valores válidos: `clear`, `clouds`, `rain`, `snow`, `thunder`, `fog`.",
                    "enum": [
                      "clear",
                      "clouds",
                      "rain",
                      "snow",
                      "thunder",
                      "fog",
                      null
                    ]
                  },
                  "last_organization_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Última organización usada (persistencia cross-device); el frontend la restaura al abrir. `null` la borra. Si es non-null debe ser una organización de la que el usuario es MIEMBRO; en caso contrario `404`."
                  },
                  "profile_visibility": {
                    "type": "string",
                    "enum": [
                      "private",
                      "organization",
                      "public"
                    ],
                    "description": "Quién ve tu perfil social (PJKT-1990). Solo el propio usuario puede cambiarlo. Volver a `private` retira el perfil al instante."
                  },
                  "social_links": {
                    "type": [
                      "object",
                      "null"
                    ],
                    "additionalProperties": {
                      "type": "string"
                    },
                    "description": "Enlaces a otras redes. Claves admitidas: `linkedin`, `x`, `github`, `instagram`, `youtube`, `mastodon`, `website`. Solo URLs http(s): una clave desconocida da `422 unknown_social_network` y una URL que no lo sea, `422 invalid_social_url`. Una cadena vacía quita ese enlace."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Perfil actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`last_organization_id` apunta a una organización inexistente o de la que el usuario no es miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/me/stats": {
      "get": {
        "operationId": "getMyStats",
        "tags": [
          "auth"
        ],
        "summary": "Mis estadísticas",
        "description": "Recuentos agregados del usuario autenticado a través de TODAS sus organizaciones (organizaciones, proyectos, tareas asignadas y completadas). Solo lectura; alimenta la cabecera del perfil `/me`.",
        "responses": {
          "200": {
            "description": "Estadísticas cruzadas del usuario.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MyStats"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/me/sessions": {
      "get": {
        "operationId": "listMySessions",
        "tags": [
          "auth"
        ],
        "summary": "Dispositivos/sesiones conectados",
        "description": "Sesiones activas del usuario autenticado (cada refresh token vivo = un dispositivo/navegador), con IP de origen, User-Agent y última actividad. La sesión de esta petición viene con `current=true`. Nunca expone el token; ordenadas por actividad reciente primero.",
        "responses": {
          "200": {
            "description": "Sesiones activas del usuario (al menos la actual).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Session"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/me/sessions/{session_id}": {
      "parameters": [
        {
          "name": "session_id",
          "in": "path",
          "required": true,
          "description": "UUID de la sesión (refresh token) a cerrar.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "revokeMySession",
        "tags": [
          "auth"
        ],
        "summary": "Cerrar sesión de un dispositivo",
        "description": "Revoca el refresh token de una sesión del usuario (cierra sesión en ese dispositivo). Sesión inexistente, ya revocada o de otro usuario → 404. Se permite revocar la sesión actual.",
        "responses": {
          "204": {
            "description": "Sesión cerrada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Falta la confirmación de identidad de la ventana sudo (`step_up_required`). NO es un problema de rol: reintentar no sirve —hay que confirmar la identidad (passkey, TOTP o código por correo) y repetir la llamada—. Un PAT nunca puede hacer step-up, así que por esa vía la respuesta es siempre 403.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Sesión inexistente, ya revocada o de otro usuario (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/me/weather": {
      "parameters": [
        {
          "name": "lat",
          "in": "query",
          "required": true,
          "description": "Latitud en grados decimales (-90..90).",
          "schema": {
            "type": "number",
            "format": "float",
            "minimum": -90,
            "maximum": 90
          }
        },
        {
          "name": "lon",
          "in": "query",
          "required": true,
          "description": "Longitud en grados decimales (-180..180).",
          "schema": {
            "type": "number",
            "format": "float",
            "minimum": -180,
            "maximum": 180
          }
        }
      ],
      "get": {
        "operationId": "getMyWeather",
        "tags": [
          "auth"
        ],
        "summary": "Condición meteorológica del usuario",
        "description": "Consulta las condiciones actuales en Open-Meteo para las coordenadas indicadas. Si el usuario tiene `weather_override` fijado, se devuelve ese pin en lugar del valor en vivo. El endpoint NUNCA devuelve 5xx aunque el proveedor externo falle: en ese caso `unavailable=true` (HTTP 200). SSRF-safe: el host está fijo en api.open-meteo.com; el usuario solo aporta lat/lon numéricos validados en rango.",
        "responses": {
          "200": {
            "description": "Condición meteorológica (o estado unavailable si el proveedor falla).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WeatherCondition"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "lat/lon fuera de rango (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/me/onboarding": {
      "get": {
        "operationId": "getMyOnboarding",
        "tags": [
          "auth"
        ],
        "summary": "Mi estado de onboarding",
        "description": "Estado de onboarding (diálogo de bienvenida, tours guiados y progreso del asistente de puesta en marcha) del usuario autenticado, persistido a nivel de USUARIO (nunca de la organización). Es la fuente de verdad cross-device del frontend; localStorage es solo caché optimista. Un usuario que aún no ha guardado nada recibe el estado vacío (`welcome_seen=false`, `tours_completed=[]`, `version=0`, `setup=null`), nunca 404.",
        "responses": {
          "200": {
            "description": "Estado de onboarding del usuario.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OnboardingState"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "put": {
        "operationId": "setMyOnboarding",
        "tags": [
          "auth"
        ],
        "summary": "Guardar mi estado de onboarding",
        "description": "Reemplaza POR COMPLETO el estado de onboarding del usuario autenticado (sustitución, no merge: el frontend siempre envía el estado completo). Idempotente: guardar dos veces el mismo estado deja el mismo resultado. Usado al descartar el welcome, al completar/saltar un tour, al avanzar por el asistente de puesta en marcha y al reiniciar los tours desde Ajustes. ÚNICA EXCEPCIÓN a la sustitución total: si el cuerpo OMITE `setup`, el progreso del asistente ya guardado se conserva (un cliente que no conoce el asistente no puede borrarlo por accidente); enviar `setup: null` lo borra y enviar el objeto lo sustituye entero. Devuelve el estado resultante.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OnboardingState"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Estado de onboarding guardado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OnboardingState"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/me/dashboard-layout": {
      "get": {
        "operationId": "getMyDashboardLayout",
        "tags": [
          "auth"
        ],
        "summary": "Mi layout del dashboard",
        "description": "Layout del dashboard (qué widgets, en qué orden y tamaño) del usuario autenticado, persistido a nivel de USUARIO. Fuente de verdad cross-device; localStorage es solo caché optimista. Un usuario que aún no ha guardado nada recibe el estado vacío (`version=1`, `items=[]`), nunca 404: el frontend lo reconcilia contra su catálogo de widgets.",
        "responses": {
          "200": {
            "description": "Layout del dashboard del usuario.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DashboardLayout"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "put": {
        "operationId": "setMyDashboardLayout",
        "tags": [
          "auth"
        ],
        "summary": "Guardar mi layout del dashboard",
        "description": "Reemplaza POR COMPLETO el layout del dashboard del usuario autenticado (sustitución, no merge: el frontend siempre envía el layout completo). Idempotente. Devuelve el layout resultante.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DashboardLayout"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Layout del dashboard guardado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DashboardLayout"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/me/ui-preferences": {
      "put": {
        "operationId": "setMyUiPreferences",
        "tags": [
          "auth"
        ],
        "summary": "Guardar mis preferencias de UI",
        "description": "Actualiza las preferencias de UI del usuario autenticado (fondo de ambiente Pro y estilo de las cards), persistidas a nivel de USUARIO como fuente de verdad cross-device (antes solo en localStorage, que se perdía al cambiar de organización o cerrar sesión). Actualización PARCIAL: solo se aplican los campos presentes en el cuerpo; enviar `ambient_background: null` quita el fondo de ambiente. Idempotente. Devuelve el `User` actualizado.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UiPreferencesUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Preferencias de UI actualizadas.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/me/work": {
      "get": {
        "operationId": "getMeWork",
        "tags": [
          "tasks"
        ],
        "summary": "Mis tareas (todas las organizaciones)",
        "description": "Todas las tareas asignadas al usuario autenticado en cualquier proyecto de cualquiera de sus organizaciones, o solo de una si se pasa `organization_id`. Mismos filtros que `getMyWork` (`status`, `overdue`) más paginación `limit`/`offset`; la respuesta incluye la cabecera `X-Total-Count` con el total de tareas que cumplen los filtros (sin paginar). Un PAT acotado a una organización o proyecto solo ve ese ámbito. Ordenado por `due_date asc nulls last`.",
        "parameters": [
          {
            "name": "organization_id",
            "in": "query",
            "required": false,
            "description": "Limita el resultado a una organización de la que el usuario es miembro. Omitido = todas sus organizaciones.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filtrar por status de tarea (los 4 base o un estado personalizado de tablero).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "overdue",
            "in": "query",
            "required": false,
            "description": "Si `true`, solo tareas con `due_date` anterior a hoy (no-done).",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Máximo de elementos a devolver (1..200).",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Página de tareas asignadas al usuario (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de tareas que cumplen los filtros (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/MyWorkTask"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/me/work/counts": {
      "get": {
        "operationId": "getMeWorkCounts",
        "tags": [
          "tasks"
        ],
        "summary": "Conteo de mis tareas abiertas por organización",
        "description": "Para cada organización de la que el usuario es miembro, el número de tareas asignadas a él que siguen abiertas (no `done` ni `cancelled`), excluyendo proyectos en papelera. Agregado ligero pensado para los badges del selector de organización en \"Mi trabajo\". Solo se incluyen organizaciones con al menos una tarea abierta. Un PAT acotado a una organización o proyecto solo cuenta ese ámbito.",
        "responses": {
          "200": {
            "description": "Lista de conteos por organización (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/MeWorkOrgCount"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/2fa": {
      "get": {
        "operationId": "getTwoFactorStatus",
        "tags": [
          "auth"
        ],
        "summary": "Estado del segundo factor",
        "description": "Si está activo, si hay un alta a medias y cuántos códigos de recuperación quedan.",
        "responses": {
          "200": {
            "description": "Estado actual.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TwoFactorStatus"
                }
              }
            }
          },
          "401": {
            "description": "Sin sesión (`unauthorized`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/2fa/setup": {
      "post": {
        "operationId": "startTwoFactorSetup",
        "tags": [
          "auth"
        ],
        "summary": "Empezar el alta del segundo factor",
        "description": "Genera un secreto TOTP y devuelve el URI `otpauth://` para el QR. NO activa el segundo factor: hace falta confirmarlo con `POST /auth/2fa/enable`. El secreto viaja una única vez, en esta respuesta. Si ya está activo → 409: para cambiar de dispositivo hay que desactivarlo primero (con código), para que una sesión robada no pueda dejar fuera al dueño regenerando el secreto.",
        "responses": {
          "200": {
            "description": "Secreto y URI del QR.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TwoFactorSetup"
                }
              }
            }
          },
          "401": {
            "description": "Sin sesión (`unauthorized`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ya está activo (`two_factor_already_enabled`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/2fa/enable": {
      "post": {
        "operationId": "enableTwoFactor",
        "tags": [
          "auth"
        ],
        "summary": "Confirmar y activar el segundo factor",
        "description": "Confirma el alta con un código de la app y devuelve los códigos de recuperación. **Es el único momento en que se muestran**: no hay endpoint que los vuelva a enseñar, porque eso convertiría cualquier sesión robada en una copia de las llaves de repuesto.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TwoFactorCode"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Segundo factor activo; códigos de recuperación (solo aquí).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RecoveryCodes"
                }
              }
            }
          },
          "400": {
            "description": "Código incorrecto (`invalid_two_factor_code`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Sin sesión (`unauthorized`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No hay alta pendiente (`two_factor_setup_missing`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ya estaba activo (`two_factor_already_enabled`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiados intentos (`rate_limited`). Cupo por usuario.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/2fa/disable": {
      "post": {
        "operationId": "disableTwoFactor",
        "tags": [
          "auth"
        ],
        "summary": "Desactivar el segundo factor",
        "description": "Exige un código válido (TOTP o de recuperación) además de la sesión. Si bastara con estar dentro, robar una sesión bastaría para quitar el segundo factor — justo lo que debía impedir.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TwoFactorCode"
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Desactivado; se borran también los códigos de recuperación."
          },
          "400": {
            "description": "Código incorrecto (`invalid_two_factor_code`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Sin sesión (`unauthorized`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No estaba activo (`two_factor_not_enabled`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiados intentos (`rate_limited`). Cupo por usuario.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/2fa/recovery-codes": {
      "post": {
        "operationId": "regenerateRecoveryCodes",
        "tags": [
          "auth"
        ],
        "summary": "Regenerar los códigos de recuperación",
        "description": "Emite una lista nueva e invalida la anterior. Exige un código válido.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TwoFactorCode"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Códigos nuevos (los anteriores dejan de valer).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RecoveryCodes"
                }
              }
            }
          },
          "400": {
            "description": "Código incorrecto (`invalid_two_factor_code`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Sin sesión (`unauthorized`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No está activo (`two_factor_not_enabled`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiados intentos (`rate_limited`). Cupo por usuario.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/2fa/verify": {
      "post": {
        "operationId": "verifyTwoFactor",
        "tags": [
          "auth"
        ],
        "summary": "Canjear el reto y abrir sesión",
        "description": "Segundo paso del acceso cuando el usuario tiene 2FA. Lee la cookie `twofa_challenge` que dejó el primer factor —contraseña, código por correo u OAuth—, comprueba el código (TOTP o de recuperación) y emite las cookies de sesión normales, borrando el reto: es de un solo uso. Acepta también un código de recuperación, que se consume para siempre.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TwoFactorCode"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Sesión abierta; cookies emitidas.",
            "headers": {
              "Set-Cookie": {
                "description": "`access_token` + `refresh_token`; el reto se borra.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          },
          "400": {
            "description": "Código incorrecto, caducado o ya usado (`invalid_two_factor_code`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Sin reto pendiente o ya caducado (`unauthorized`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiados intentos (`rate_limited`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/login-code": {
      "post": {
        "operationId": "requestLoginCode",
        "tags": [
          "auth"
        ],
        "summary": "Solicitar código de acceso",
        "description": "Envía al email un código de 6 dígitos válido 10 minutos y de un solo uso. Pedir un código invalida el anterior de ese email (solo uno vivo a la vez). Responde SIEMPRE 200 con un mensaje genérico, exista o no la cuenta: no filtra enumeración de usuarios.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Solicitud aceptada (mensaje genérico; no revela si el email existe).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LoginCodeRequestResult"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones (`rate_limited`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/login-code/verify": {
      "post": {
        "operationId": "verifyLoginCode",
        "tags": [
          "auth"
        ],
        "summary": "Verificar código de acceso",
        "description": "Canjea el código y abre sesión emitiendo las cookies `access_token` + `refresh_token`, idénticas a un login normal. Si el email no tenía cuenta, se crea sin contraseña: acertar el código demuestra el control del buzón. El email es obligatorio porque el código está ligado a él —el hash guardado es un HMAC del par (código, email)—, de modo que un código emitido para una cuenta no sirve para otra aunque el número coincida.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email",
                  "code"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "code": {
                    "type": "string",
                    "description": "Los 6 dígitos recibidos por correo.",
                    "pattern": "^[0-9]{6}$"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Sesión abierta; cookies emitidas.",
            "headers": {
              "Set-Cookie": {
                "description": "`access_token` (JWT) y `refresh_token` (opaco rotativo).",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          },
          "400": {
            "description": "`invalid_login_code`. Es la MISMA respuesta para todos los fallos —no hay código, caducó, es incorrecto o agotó los 5 intentos—: distinguirlos delataría si esa cuenta tiene una petición de acceso en curso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido o el código no son 6 dígitos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiados intentos (`rate_limited`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/oauth/{provider}/start": {
      "parameters": [
        {
          "name": "provider",
          "in": "path",
          "required": true,
          "description": "Proveedor OAuth.",
          "schema": {
            "type": "string",
            "enum": [
              "google",
              "github"
            ]
          }
        }
      ],
      "get": {
        "operationId": "oauthStart",
        "tags": [
          "auth"
        ],
        "summary": "Iniciar OAuth (redirect al proveedor)",
        "description": "Genera un `state` anti-CSRF (solo su SHA-256 se guarda) y redirige (302) a la URL de autorización del proveedor. Si el proveedor no está configurado (sin client_id) responde 503. `client=ops` hace que el callback redirija al panel ops (`OPS_URL`) en lugar de a la web (`WEB_URL`).",
        "parameters": [
          {
            "name": "client",
            "in": "query",
            "required": false,
            "description": "App que inicia el login. `web` (defecto) redirige tras el callback a `{WEB_URL}/dashboard`; `movil` redirige a `{WEB_URL}/m`, que es la app del teléfono —mismo origen que la web, porque la cookie de sesión es host-only—; `ops` redirige a `{OPS_URL}/` (o `{OPS_URL}/login?error=…` ante fallo). Viaja dentro del `state` firmado, nunca como parámetro libre en el callback.",
            "schema": {
              "type": "string",
              "enum": [
                "web",
                "movil",
                "ops"
              ],
              "default": "web"
            }
          }
        ],
        "security": [],
        "responses": {
          "302": {
            "description": "Redirección a la pantalla de consentimiento del proveedor.",
            "headers": {
              "Location": {
                "description": "URL de autorización del proveedor (con client_id, redirect_uri, scope, state).",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "description": "Proveedor desconocido (`unknown_provider`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "OAuth no configurado para el proveedor (`oauth_not_configured`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/oauth/{provider}/callback": {
      "parameters": [
        {
          "name": "provider",
          "in": "path",
          "required": true,
          "description": "Proveedor OAuth.",
          "schema": {
            "type": "string",
            "enum": [
              "google",
              "github"
            ]
          }
        },
        {
          "name": "code",
          "in": "query",
          "required": false,
          "description": "Código de autorización devuelto por el proveedor.",
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "state",
          "in": "query",
          "required": false,
          "description": "State anti-CSRF emitido en `start` (se valida contra su hash en DB).",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "oauthCallback",
        "tags": [
          "auth"
        ],
        "summary": "Callback de OAuth (abre sesión y redirige)",
        "description": "Valida el `state`, canjea el `code` por un token del proveedor, obtiene el email verificado y hace find-or-create del usuario (sin duplicar; entra a la cuenta existente/migrada si el email casa). Emite las cookies de sesión y redirige (302) a `{WEB_URL}/dashboard`; ante cualquier fallo redirige a `{WEB_URL}/login?error=<code>` (sin filtrar detalles).",
        "security": [],
        "responses": {
          "302": {
            "description": "Sesión abierta → `/dashboard` (con cookies), o `/login?error=…` en fallo.",
            "headers": {
              "Set-Cookie": {
                "description": "`access_token` + `refresh_token` (solo en el caso de éxito).",
                "schema": {
                  "type": "string"
                }
              },
              "Location": {
                "description": "`{WEB_URL}/dashboard` en éxito, o `{WEB_URL}/login?error=…` en fallo.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "429": {
            "description": "Demasiados callbacks (`too_many_requests`) desde la misma IP. A diferencia del resto de respuestas de esta operación NO es un redirect: es el envelope de error, porque el cupo se aplica antes del handler.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/passkey/register/begin": {
      "post": {
        "operationId": "passkeyRegisterBegin",
        "tags": [
          "auth"
        ],
        "summary": "Iniciar registro de passkey",
        "description": "Devuelve las opciones de creación WebAuthn para el usuario autenticado; el challenge se guarda server-side (un solo uso, caduca en minutos).",
        "responses": {
          "200": {
            "description": "Opciones de registro WebAuthn.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebAuthnOptions"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/passkey/register/complete": {
      "post": {
        "operationId": "passkeyRegisterComplete",
        "tags": [
          "auth"
        ],
        "summary": "Completar registro de passkey",
        "description": "Verifica la attestation del navegador contra el challenge guardado y almacena la credencial (clave pública + sign_count). Respuesta inválida o challenge caducado → 400.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "credential"
                ],
                "properties": {
                  "credential": {
                    "type": "object",
                    "additionalProperties": true,
                    "description": "Respuesta de attestation de `navigator.credentials.create()`."
                  },
                  "name": {
                    "type": "string",
                    "description": "Nombre legible opcional para la passkey."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Passkey registrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Passkey"
                }
              }
            }
          },
          "400": {
            "description": "Attestation inválida o challenge caducado (`passkey_*`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/passkey/login/begin": {
      "post": {
        "operationId": "passkeyLoginBegin",
        "tags": [
          "auth"
        ],
        "summary": "Iniciar login por passkey",
        "description": "Devuelve las opciones de autenticación WebAuthn. Con `email`, acota `allowCredentials` a las passkeys de ese usuario (no filtra existencia).",
        "security": [],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email",
                    "description": "Opcional; acota las credenciales permitidas."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Opciones de autenticación WebAuthn.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebAuthnOptions"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiados inicios de login por passkey (`too_many_requests`): 15/min por IP, ventana fija de 60 s; fail-open si Redis no responde.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/passkey/login/complete": {
      "post": {
        "operationId": "passkeyLoginComplete",
        "tags": [
          "auth"
        ],
        "summary": "Completar login por passkey",
        "description": "Verifica la assertion contra el challenge guardado, incrementa `sign_count` y abre sesión (cookies `access_token` + `refresh_token`, como un login). Assertion inválida o challenge caducado → 400.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "credential"
                ],
                "properties": {
                  "credential": {
                    "type": "object",
                    "additionalProperties": true,
                    "description": "Respuesta de assertion de `navigator.credentials.get()`."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Sesión abierta; cookies emitidas.",
            "headers": {
              "Set-Cookie": {
                "description": "`access_token` (JWT 15 min) y `refresh_token` (opaco rotativo).",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          },
          "400": {
            "description": "Assertion inválida o challenge caducado (`passkey_*`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas assertions (`too_many_requests`): 15/min por IP, ventana fija de 60 s; fail-open si Redis no responde.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/passkey/credentials": {
      "get": {
        "operationId": "listPasskeys",
        "tags": [
          "auth"
        ],
        "summary": "Listar passkeys",
        "description": "Passkeys del usuario autenticado, enmascaradas (sin clave pública).",
        "responses": {
          "200": {
            "description": "Lista de passkeys (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Passkey"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/passkey/credentials/{credential_id}": {
      "parameters": [
        {
          "name": "credential_id",
          "in": "path",
          "required": true,
          "description": "UUID de la passkey.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "revokePasskey",
        "tags": [
          "auth"
        ],
        "summary": "Revocar passkey",
        "description": "Elimina una passkey del usuario autenticado. Ajena o inexistente → 404.",
        "responses": {
          "204": {
            "description": "Passkey eliminada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Falta la confirmación de identidad de la ventana sudo (`step_up_required`). NO es un problema de rol: reintentar no sirve —hay que confirmar la identidad (passkey, TOTP o código por correo) y repetir la llamada—. Un PAT nunca puede hacer step-up, así que por esa vía la respuesta es siempre 403.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Passkey inexistente o de otro usuario (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/step-up/methods": {
      "get": {
        "operationId": "getStepUpMethods",
        "tags": [
          "auth"
        ],
        "summary": "Factores disponibles para confirmar",
        "description": "Factores con los que el usuario en sesión puede reconfirmar su identidad (passkey / TOTP / código email). El modal ofrece el más fuerte disponible.",
        "responses": {
          "200": {
            "description": "Factores disponibles.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StepUpMethods"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/step-up/passkey/begin": {
      "post": {
        "operationId": "stepUpPasskeyBegin",
        "tags": [
          "auth"
        ],
        "summary": "Iniciar confirmación con passkey",
        "description": "Opciones de assertion WebAuthn acotadas a las passkeys del usuario EN SESIÓN. 404 si no tiene ninguna passkey registrada.",
        "responses": {
          "200": {
            "description": "Opciones de autenticación WebAuthn.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebAuthnOptions"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Sin passkeys registradas.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/step-up/passkey/complete": {
      "post": {
        "operationId": "stepUpPasskeyComplete",
        "tags": [
          "auth"
        ],
        "summary": "Completar confirmación con passkey",
        "description": "Verifica la assertion (debe ser de una passkey del usuario en sesión) y adjunta la cookie `step_up`. No abre sesión.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "credential"
                ],
                "properties": {
                  "credential": {
                    "type": "object",
                    "additionalProperties": true,
                    "description": "Respuesta de assertion de `navigator.credentials.get()`."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Confirmación realizada; cookie `step_up` emitida.",
            "headers": {
              "Set-Cookie": {
                "description": "Cookie `step_up` (JWT corto de confirmación).",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StepUpResult"
                }
              }
            }
          },
          "400": {
            "description": "Assertion inválida o challenge caducado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiados intentos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/step-up/totp": {
      "post": {
        "operationId": "stepUpTotp",
        "tags": [
          "auth"
        ],
        "summary": "Confirmar con código 2FA (TOTP o recuperación)",
        "description": "Verifica un código TOTP o de recuperación del usuario en sesión y adjunta la cookie `step_up`. Mismo control de fuerza bruta que el canje del login.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "code"
                ],
                "properties": {
                  "code": {
                    "type": "string",
                    "description": "Código TOTP (6 dígitos) o de recuperación."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Confirmación realizada; cookie `step_up` emitida.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StepUpResult"
                }
              }
            }
          },
          "400": {
            "description": "Código inválido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "El segundo factor no está activo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiados intentos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/step-up/email/request": {
      "post": {
        "operationId": "stepUpEmailRequest",
        "tags": [
          "auth"
        ],
        "summary": "Enviarme un código de confirmación por email",
        "description": "Envía un código de un solo uso al email del usuario en sesión. Respuesta genérica. Rate-limited.",
        "responses": {
          "200": {
            "description": "Código enviado (respuesta genérica).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StepUpMessage"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/step-up/email/verify": {
      "post": {
        "operationId": "stepUpEmailVerify",
        "tags": [
          "auth"
        ],
        "summary": "Confirmar con el código recibido por email",
        "description": "Verifica el código de email de step-up del usuario en sesión y adjunta la cookie `step_up`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "code"
                ],
                "properties": {
                  "code": {
                    "type": "string",
                    "description": "Código de 6 dígitos recibido por email."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Confirmación realizada; cookie `step_up` emitida.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StepUpResult"
                }
              }
            }
          },
          "400": {
            "description": "Código inválido o caducado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiados intentos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations": {
      "get": {
        "operationId": "listOrganizations",
        "x-tool": [
          {
            "name": "list_organizations",
            "domain": "org",
            "profile": "core",
            "sensitive": false
          },
          {
            "name": "get_context",
            "domain": "org",
            "profile": "core",
            "sensitive": false
          }
        ],
        "tags": [
          "organizations"
        ],
        "summary": "Listar organizaciones",
        "description": "Organizaciones a las que pertenece el usuario, con su rol en cada una.",
        "responses": {
          "200": {
            "description": "Lista de organizaciones (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Organization"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createOrganization",
        "tags": [
          "organizations"
        ],
        "summary": "Crear organización",
        "description": "Crea una organización; el creador queda como `owner`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "slug"
                ],
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "slug": {
                    "type": "string",
                    "description": "Identificador único URL-safe."
                  },
                  "logo_url": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "URL de logotipo opcional; puede omitirse o enviarse `null`."
                  },
                  "country": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "minLength": 2,
                    "maxLength": 2,
                    "description": "País ISO 3166-1 alfa-2. Omitido (o `null`) = `ES`. Fijarlo aquí evita que una organización estadounidense nazca emitiendo documentos españoles y haya que corregirlos después.",
                    "examples": [
                      "US"
                    ]
                  },
                  "default_currency": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "minLength": 3,
                    "maxLength": 3,
                    "description": "Divisa base ISO 4217. Omitida (o `null`) = `EUR`.",
                    "examples": [
                      "USD"
                    ]
                  },
                  "locale": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "maxLength": 10,
                    "description": "Locale BCP-47. Omitido (o `null`) = `es-ES`.",
                    "examples": [
                      "en-US"
                    ]
                  },
                  "timezone": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Zona horaria IANA. Omitida = `Europe/Madrid`. Se pide en el alta —no solo en los ajustes— porque es la que decide qué instantes cuentan como jornada laboral: una organización estadounidense que naciera en `Europe/Madrid` calcularía sus SLA en hora de Madrid hasta que alguien lo notara. Se valida contra la base de datos de zonas: un nombre que no exista responde 422.",
                    "examples": [
                      "America/New_York"
                    ]
                  },
                  "week_start_day": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "enum": [
                      0,
                      5,
                      6,
                      null
                    ],
                    "description": "Día en que empieza la semana (`0` lunes, `5` sábado, `6` domingo). OMITIDO (o `null`) NO ES LUNES: se deduce del `country` de esta misma petición (`US` → domingo, `ES` → lunes) y, si no hay país, del `locale`. Enviarlo explícitamente gana siempre.",
                    "examples": [
                      6
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Organización creada (rol `owner`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Organization"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Slug ya en uso (`slug_taken`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getOrganization",
        "tags": [
          "organizations"
        ],
        "summary": "Detalle de organización",
        "description": "Devuelve la organización si el usuario es miembro; 404 si no existe o no es miembro.",
        "responses": {
          "200": {
            "description": "Organización.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Organization"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No existe o el usuario no es miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateOrganization",
        "x-tool": {
          "name": "update_organization",
          "domain": "admin",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "organizations"
        ],
        "summary": "Actualizar organización",
        "description": "Actualización parcial; solo `owner` o `admin` (403 para el resto de miembros).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "slug": {
                    "type": "string",
                    "description": "Identificador corto de la organización. Es ÚNICO en toda la plataforma, así que renombrarlo a uno ya ocupado responde 409 `slug_taken`. No forma parte de ninguna URL (las rutas de la app y los enlaces públicos usan ids y tokens opacos), por eso puede cambiarse sin romper enlaces ya compartidos.",
                    "pattern": "^[a-z0-9](-?[a-z0-9])*$",
                    "minLength": 2,
                    "maxLength": 63,
                    "examples": [
                      "3xa-engineering"
                    ]
                  },
                  "logo_url": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra el logotipo."
                  },
                  "brand_color": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Color de marca en hex CSS (#RRGGBB); `null` borra el color.",
                    "examples": [
                      "#FD2554"
                    ]
                  },
                  "accent_color": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Color de acento en hex CSS (#RRGGBB); `null` borra el color.",
                    "examples": [
                      "#1A1A1A"
                    ]
                  },
                  "legal_name": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Razón social legal; `null` borra el valor.",
                    "examples": [
                      "3XA Inc S.L."
                    ]
                  },
                  "fiscal_address": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Domicilio fiscal; `null` borra el valor.",
                    "examples": [
                      "Calle Mayor 1, 28001 Madrid, España"
                    ]
                  },
                  "tax_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "NIF/CIF de la organización; `null` borra el valor.",
                    "examples": [
                      "B12345678"
                    ]
                  },
                  "iban": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "IBAN bancario (sin espacios); `null` borra el valor.",
                    "examples": [
                      "ES9121000418450200051332"
                    ]
                  },
                  "bic": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "BIC/SWIFT del banco; `null` borra el valor.",
                    "examples": [
                      "CAIXESBBXXX"
                    ]
                  },
                  "bank_name": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Nombre del banco; `null` borra el valor.",
                    "examples": [
                      "CaixaBank"
                    ]
                  },
                  "automations_enabled": {
                    "type": "boolean",
                    "description": "Interruptor global de automatizaciones de la organización (PJKT-1848). `true` las activa, `false` las desactiva todas."
                  },
                  "external_messaging_enabled": {
                    "type": "boolean",
                    "description": "Abre o cierra la puerta del chat entre organizaciones. `true` hace a esta organización alcanzable desde el directorio externo de OTRAS organizaciones que también lo tengan abierto; `false` (el valor de fábrica) la saca del directorio y corta los hilos externos que ya existieran — no los borra, deja de servirlos. Como el resto de ajustes de la organización, lo cambian owner y admin."
                  },
                  "timezone": {
                    "type": "string",
                    "description": "Zona horaria IANA de la organización (p. ej. `Europe/Madrid`, `Atlantic/Canary`). Define en qué hora local se interpreta su jornada laboral y, con ella, qué tiempo cuenta como laborable. Se valida contra la base de datos de zonas: un nombre que no exista responde 422. No admite `null` (la columna es NOT NULL).",
                    "examples": [
                      "Europe/Madrid"
                    ]
                  },
                  "country": {
                    "type": "string",
                    "minLength": 2,
                    "maxLength": 2,
                    "description": "País de la organización en ISO 3166-1 alfa-2 (`ES`, `US`…). Se normaliza a mayúsculas; una forma que no sean dos letras responde 422. No admite `null` (la columna es NOT NULL).",
                    "examples": [
                      "US"
                    ]
                  },
                  "default_currency": {
                    "type": "string",
                    "minLength": 3,
                    "maxLength": 3,
                    "description": "Divisa base en ISO 4217 (`EUR`, `USD`…). Se normaliza a mayúsculas; una forma que no sean tres letras responde 422. Cambiarla NO reconvierte los documentos ya emitidos: solo decide la divisa de los siguientes y la unidad del resumen financiero. No admite `null` (la columna es NOT NULL).",
                    "examples": [
                      "USD"
                    ]
                  },
                  "locale": {
                    "type": "string",
                    "maxLength": 10,
                    "description": "Locale BCP-47 (`es-ES`, `en-US`, `en`). Se normaliza (`en_us` → `en-US`); una forma inválida responde 422. No admite `null` (la columna es NOT NULL).",
                    "examples": [
                      "en-US"
                    ]
                  },
                  "week_start_day": {
                    "type": "integer",
                    "enum": [
                      0,
                      5,
                      6
                    ],
                    "description": "Día en que empieza la semana: `0` lunes, `5` sábado, `6` domingo (convención `weekday()` de Python). Cualquier otro valor responde 422. Cambiarlo mueve los límites de semana del planner, el calendario, los partes de horas y la capacidad; NO reubica los periodos de parte de horas ya creados con el anterior, que siguen guardados con su ancla original. No admite `null` (la columna es NOT NULL).",
                    "examples": [
                      6
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Organización actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Organization"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `owner` o `admin` (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No existe o el usuario no es miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "El `slug` pedido ya lo usa otra organización (`slug_taken`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteOrganization",
        "tags": [
          "organizations"
        ],
        "summary": "Eliminar organización",
        "description": "Borrado SOFT de la organización: marca `deleted_at` y la organización deja de ser accesible (el resto de endpoints org-scoped responden 403 `organization_suspended`) y deja de listarse en `GET /organizations`. Los datos que cuelgan de ella (miembros, proyectos, facturación, PATs, adjuntos) NO se borran: el borrado físico en cascada es la purga del panel de plataforma, con periodo de gracia.\n\nSolo el `owner` puede borrarla: un `admin` recibe 403. Un no-miembro recibe 404, igual que si la organización no existiera. Repetir el DELETE sobre una organización ya borrada devuelve 403 `organization_suspended` (deja de ser accesible para su propio owner).\n\nSOLO SESIÓN DE NAVEGADOR: con credenciales de API (`Authorization: Bearer` — PAT `pjk_live_…` u OAuth `pjk_oat_…`) la petición se rechaza con 403 `interactive_session_required`, incluso siendo `owner`. Esas credenciales viven desatendidas en scripts, aplicaciones de terceros y el conector MCP, y este borrado arrastra proyectos, facturación, adjuntos y miembros, así que ese canal está cerrado.",
        "responses": {
          "204": {
            "description": "Organización borrada (sin cuerpo)."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `owner` (`forbidden`), organización ya borrada (`organization_suspended`) o petición sin sesión de navegador (`interactive_session_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No existe o el usuario no es miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/members": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listOrganizationMembers",
        "x-tool": {
          "name": "list_members",
          "domain": "org",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "organizations"
        ],
        "summary": "Listar miembros",
        "description": "Miembros de la organización con su rol; requiere ser miembro.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de miembros (al menos el `owner`).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de miembros de la organización (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Member"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/members/bulk": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "bulkUpdateMembers",
        "tags": [
          "organizations"
        ],
        "summary": "Actualización masiva de miembros",
        "description": "Aplica `department_id` y/o `position` a una lista de miembros de la organización. El mismo comportamiento que PATCH individual: si `department_id` cambia, sincroniza el string `employees.department` con el nombre del departamento. `null` desvincula el departamento. Requiere manager+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MemberBulkUpdateIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Resultado con conteo de éxitos y lista de errores.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MemberBulkResult"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente; manager+ requerido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (user_ids vacío o campos ausentes).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/members/{user_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "user_id",
          "in": "path",
          "required": true,
          "description": "UUID del usuario miembro.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "updateMember",
        "x-tool": {
          "name": "update_member_role",
          "domain": "admin",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "organizations"
        ],
        "summary": "Actualizar miembro (rol y/o datos RRHH)",
        "description": "Actualización parcial de un miembro. `role` requiere owner/admin; solo un `owner` puede otorgar o revocar el rol `owner`; no se puede degradar al último `owner` (409 `last_owner`). Los campos RRHH (`department`, `position`, `phone`, `hire_date`, `employment_type`, `is_active`) requieren manager+ y generan UPSERT de la ficha employee vinculada por email. Un miembro desactivado (`is_active: false`) no se elimina de la organización.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MemberRoleUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Miembro actualizado (con su nuevo rol).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Member"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente, o intento de otorgar/revocar `owner` sin ser owner (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización o miembro inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "No se puede degradar al último owner (`last_owner`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "removeMember",
        "x-tool": {
          "name": "remove_member",
          "domain": "admin",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "organizations"
        ],
        "summary": "Eliminar un miembro",
        "description": "Expulsa a un miembro de la organización. Requiere owner/admin; un usuario puede eliminarse a sí mismo (abandonar) sin ser admin, salvo que sea el último `owner`. No se puede eliminar al último `owner` (409 `last_owner`).",
        "responses": {
          "204": {
            "description": "Miembro eliminado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente para expulsar a otro miembro (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización o miembro inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "No se puede eliminar al último owner (`last_owner`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/members/invite": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "inviteMember",
        "x-tool": {
          "name": "invite_member",
          "domain": "admin",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "organizations"
        ],
        "summary": "Invitar a un miembro",
        "description": "Invita a un usuario a la organización por email o nombre de usuario. Si el usuario ya existe y no es miembro, se añade directamente (response contiene `member`). Si el email no está registrado, se crea una invitación pendiente y se envía un correo (response contiene `invite`). Si ya hay una invitación pendiente vigente para ese email, se devuelve la existente (idempotente). Si el usuario ya es miembro, 409 `already_member`. Requiere rol `admin` o superior.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/InviteMemberIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Resultado de la invitación.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InviteResult"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `admin` o superior (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente, usuario no miembro, o identificador no encontrado (para nombres de usuario) (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "El usuario ya es miembro (`already_member`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/members/invite-bulk": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "bulkInviteMembers",
        "tags": [
          "organizations"
        ],
        "summary": "Alta en lote de miembros por correo",
        "description": "Da de alta una lista de correos con un mismo rol, sin invitar de uno en uno. Por cada correo aplica exactamente la misma regla que `inviteMember`: si ya tiene cuenta y no es miembro se añade directamente (`added`), si no la tiene se crea una invitación pendiente y el correo se envía FUERA de la petición (`invited`).\n\nLa respuesta es 200 con UNA FILA POR CORREO (`results`), nunca un éxito o fracaso global: los ya miembros, los que ya tenían invitación, los repetidos de la propia lista y los correos mal formados se reportan individualmente y NO abortan el lote.\n\nLas plazas disponibles del plan se calculan ANTES de dar de alta a nadie (miembros actuales + invitaciones pendientes frente a `max_members`), y los correos que no caben se marcan `plan_limit_reached` en vez de reventar a mitad de la lista. Requiere rol `admin` o superior y confirmación de seguridad (step-up), igual que la invitación individual.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BulkInviteIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Resultado por correo + contadores agregados.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BulkInviteResult"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `admin` o superior (`forbidden`), o falta la confirmación de seguridad (`step_up_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido: lista vacía o de más de 200 correos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/domains": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listOrganizationDomains",
        "tags": [
          "organizations"
        ],
        "summary": "Listar dominios corporativos",
        "description": "Dominios corporativos reclamados por la organización. Requiere rol `admin`/`owner` y un plan que incluya la función (`corporate_domains`, hoy Enterprise): en el resto responde `403 feature_not_available`, así que la funcionalidad no aparece ni por API.",
        "responses": {
          "200": {
            "description": "Dominios de la organización.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/OrganizationDomain"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Plan sin la función (`feature_not_available`) o rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada o sin acceso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "claimOrganizationDomain",
        "tags": [
          "organizations"
        ],
        "summary": "Reclamar un dominio corporativo",
        "description": "Reclama el dominio y envía un código de 6 dígitos a una dirección del propio dominio. NO concede nada: hasta verificarlo el dominio no captura a nadie.\n\nEl dominio es único de forma GLOBAL: si ya está reclamado responde `409 domain_taken` **sin revelar qué organización lo tiene**. Si el dominio ya es de esta organización y estaba a medias, se reenvía un código nuevo.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OrganizationDomainClaim"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Dominio reclamado; código enviado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OrganizationDomain"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Plan sin la función (`feature_not_available`) o rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada o sin acceso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ya reclamado por otra organización (`domain_taken`) o ya verificado en esta (`domain_already_verified`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Dominio con formato inválido (`invalid_domain`), proveedor público (`public_domain`) o dirección de verificación que no es del dominio (`verification_email_mismatch`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/domains/{domain_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "domain_id",
          "in": "path",
          "required": true,
          "description": "UUID del dominio.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "setOrganizationDomainEnforcement",
        "tags": [
          "organizations"
        ],
        "summary": "Activar o desactivar el modo obligatorio",
        "description": "Con el modo activo, quien tenga correo de este dominio pertenece a la organización: un alta nueva entra directa como `member` y los usuarios ya registrados quedan vinculados en el momento de activarlo. Además dejan de poder crear organizaciones propias (`403 domain_managed_account`).\n\nLo que esos usuarios ya tuvieran NO se toca: sus organizaciones anteriores se conservan y solo se impide crear nuevas. Requiere dominio verificado.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OrganizationDomainEnforce"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Dominio actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OrganizationDomain"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Plan sin la función (`feature_not_available`) o rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Dominio u organización no encontrados (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "El dominio no está verificado (`domain_not_verified`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "releaseOrganizationDomain",
        "tags": [
          "organizations"
        ],
        "summary": "Liberar un dominio corporativo",
        "description": "Suelta el dominio: deja de capturar altas y vuelve a estar reclamable por cualquiera. Las membresías ya creadas se CONSERVAN — quien entró por el dominio sigue en la organización.",
        "responses": {
          "204": {
            "description": "Dominio liberado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Plan sin la función (`feature_not_available`) o rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Dominio u organización no encontrados (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/domains/{domain_id}/verify": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "domain_id",
          "in": "path",
          "required": true,
          "description": "UUID del dominio.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "verifyOrganizationDomain",
        "tags": [
          "organizations"
        ],
        "summary": "Verificar un dominio con el código del correo",
        "description": "Consume el código enviado al reclamar el dominio. De UN SOLO USO: al acertar, el código se destruye. Caduca a los 30 minutos y se invalida tras 5 intentos fallidos — a partir de ahí hay que reclamar el dominio otra vez.\n\nVerificar NO activa el modo obligatorio: son dos decisiones distintas y se auditan por separado.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OrganizationDomainVerify"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Dominio verificado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OrganizationDomain"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Plan sin la función (`feature_not_available`) o rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Dominio u organización no encontrados (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Código incorrecto (`verification_invalid`), caducado (`verification_expired`), agotados los intentos (`verification_attempts_exceeded`) o sin verificación en curso (`verification_not_pending`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/members/invites": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listOrgInvites",
        "x-tool": {
          "name": "list_org_invites",
          "domain": "admin",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "organizations"
        ],
        "summary": "Listar invitaciones pendientes",
        "description": "Lista las invitaciones pendientes (no aceptadas y no caducadas) de la organización. Requiere rol `admin` o superior.",
        "responses": {
          "200": {
            "description": "Lista de invitaciones pendientes (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/OrgInvite"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `admin` o superior (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/members/invites/{invite_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "invite_id",
          "in": "path",
          "required": true,
          "description": "UUID de la invitación.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "revokeOrgInvite",
        "x-tool": {
          "name": "revoke_org_invite",
          "domain": "admin",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "organizations"
        ],
        "summary": "Revocar una invitación",
        "description": "Elimina una invitación pendiente. Requiere rol `admin` o superior.",
        "responses": {
          "204": {
            "description": "Invitación revocada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `admin` o superior (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente, usuario no miembro, o invitación no encontrada (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/members/invites/{invite_id}/resend": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "invite_id",
          "in": "path",
          "required": true,
          "description": "UUID de la invitación.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "resendOrgInvite",
        "tags": [
          "organizations"
        ],
        "summary": "Reenviar una invitación pendiente",
        "description": "Vuelve a enviar el correo de una invitación pendiente, con su token de siempre. Existe porque el envío original puede perderse (spam, buzón lleno) y hasta ahora no había manera de repetirlo: volver a invitar al mismo correo era idempotente y NO reenviaba nada, así que la invitación moría en silencio a los 7 días. Requiere rol `admin` o superior.",
        "responses": {
          "204": {
            "description": "Correo reenviado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `admin` o superior (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente, usuario no miembro, o invitación no encontrada, ya aceptada o caducada (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiados reenvíos seguidos (`rate_limited`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/invites/{token}": {
      "get": {
        "operationId": "getInviteByToken",
        "tags": [
          "organizations"
        ],
        "summary": "Ver una invitación por su token",
        "description": "Ficha pública de la invitación para pintar la pantalla de aceptación sin sesión. Responde 200 también cuando la invitación está caducada o ya se aceptó (el estado va en `status`): son situaciones que el invitado tiene derecho a entender, y quien pregunta ya tiene el token que le llegó por correo. 404 solo si el token no existe.",
        "security": [],
        "parameters": [
          {
            "name": "token",
            "in": "path",
            "required": true,
            "description": "Token opaco de la invitación (el del enlace del correo).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Ficha de la invitación.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OrgInvitePreview"
                }
              }
            }
          },
          "404": {
            "description": "No existe ninguna invitación con ese token (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones (`rate_limited`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/invites/{token}/accept": {
      "post": {
        "operationId": "acceptInvite",
        "tags": [
          "organizations"
        ],
        "summary": "Aceptar una invitación",
        "description": "Da de alta al usuario AUTENTICADO en la organización con el rol de la invitación y la marca como aceptada. Idempotente: si ya es miembro, responde 200 con la organización y no duplica nada.\n\nLa invitación se acepta por CORREO, no por enlace: si el correo de la sesión no es el invitado, responde 403 `invite_email_mismatch` y no toca nada. Ese es el caso de quien abre el enlace con otra cuenta abierta en el navegador —el más frecuente y el que peor se explica solo—: la respuesta dice a quién iba dirigida para que la pantalla pueda ofrecer cambiar de cuenta.",
        "parameters": [
          {
            "name": "token",
            "in": "path",
            "required": true,
            "description": "Token opaco de la invitación (el del enlace del correo).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Alta hecha (o ya existía). Devuelve la organización con el rol.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Organization"
                }
              }
            }
          },
          "401": {
            "description": "Sin sesión (`unauthorized`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "La invitación es para otro correo (`invite_email_mismatch`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No existe ninguna invitación con ese token (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "La invitación caducó (`invite_expired`); hay que pedir otra.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones (`rate_limited`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/org-chart": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getOrgChart",
        "tags": [
          "organizations"
        ],
        "summary": "Organigrama de la organización",
        "description": "Devuelve el árbol jerárquico de empleados (organigrama) basado en la cadena `manager_id`. Los nodos raíz son los empleados sin manager_id (o cuyo manager no pertenece a la org). Cada nodo incluye sus reportes directos en `children` (recursivo). Requiere member+.",
        "responses": {
          "200": {
            "description": "Árbol del organigrama (puede ser una lista vacía si no hay empleados).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/OrgChartNode"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/export": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "exportOrgData",
        "tags": [
          "organizations"
        ],
        "summary": "Exportar datos de la organización (ZIP)",
        "description": "Genera y descarga un archivo ZIP con los datos de la organización en CSV: `projects.csv`, `tasks.csv`, `clients.csv`, `invoices.csv`, `expenses.csv`. Los proyectos y clientes en papelera se excluyen. Requiere rol `owner` o `admin`.",
        "responses": {
          "200": {
            "description": "Archivo ZIP con los CSVs de la organización.",
            "content": {
              "application/zip": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `owner` o `admin` (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/audit": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listOrganizationAudit",
        "x-tool": {
          "name": "list_org_audit",
          "domain": "admin",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "audit"
        ],
        "summary": "Leer el audit log de la organización",
        "description": "Lectura paginada del historial de auditoría de ESTA organización (miembros, proyectos, tareas, api-keys…), más recientes primero. Requiere rol `owner` o `admin`. SIEMPRE filtrado por la organización del path — nunca devuelve filas de otra organización. Excluye siempre las acciones internas `platform.*` del panel OPS (impersonation, purga, suspensión, plan, feature flags…): no son actividad propia de la organización, sino operaciones del operador de la plataforma sobre ella.",
        "parameters": [
          {
            "name": "action",
            "in": "query",
            "required": false,
            "description": "Filtrar por código de acción exacto (`project.created`…).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "actor_id",
            "in": "query",
            "required": false,
            "description": "Filtrar por el usuario que ejecutó la acción (el actor).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "date_from",
            "in": "query",
            "required": false,
            "description": "`created_at` >= (fecha-hora ISO 8601, inclusive).",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "date_to",
            "in": "query",
            "required": false,
            "description": "`created_at` <= (fecha-hora ISO 8601, inclusive).",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Entradas de audit de la organización (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de entradas que cumplen los filtros (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/OrgAuditLogEntry"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `owner` o `admin` (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/search": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "globalSearch",
        "x-tool": {
          "name": "search",
          "domain": "search",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "search"
        ],
        "summary": "Búsqueda global",
        "description": "Busca de forma case-insensitive en tareas, proyectos, clientes, facturas, gastos, documentos, empleados, proveedores y miembros de la organización. Devuelve hasta 5 resultados por tipo. Los proyectos/clientes/documentos en papelera no aparecen. Las entidades financieras (invoice, expense, supplier) solo se incluyen si el rol del llamante es member o superior. Requiere ser miembro.",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "description": "Término de búsqueda (mínimo 1 carácter).",
            "schema": {
              "type": "string",
              "minLength": 1
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por tipo (1–50; defecto 8).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 50,
              "default": 8
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista plana de hits ordenados por tipo + relevancia. Puede ser vacía si no hay coincidencias.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/SearchHit"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetro `q` ausente o inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/entitlements": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getEntitlements",
        "tags": [
          "billing"
        ],
        "summary": "Plan, features, límites y uso de la organización",
        "description": "Devuelve el plan freemium efectivo de la organización, las funcionalidades que desbloquea, sus límites (proyectos, miembros, almacenamiento; `null` = ilimitado) y el uso actual. El frontend lo usa para mostrar el plan, atenuar lo no incluido y avisar al acercarse a un límite.",
        "responses": {
          "200": {
            "description": "Entitlements de la organización.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Entitlements"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada o sin acceso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/modules": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listOrganizationModules",
        "tags": [
          "billing"
        ],
        "summary": "Módulos que la organización puede encender o apagar",
        "description": "Devuelve los módulos que el plan de la organización INCLUYE, cada uno con su interruptor y con los módulos que quedarían degradados al apagarlo. Lo que el plan no incluye no sale: eso es «mejorar plan», no este interruptor. Requiere owner/admin — el mismo umbral que cambiar cualquier otro ajuste de la organización.",
        "responses": {
          "200": {
            "description": "Módulos disponibles para la organización.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/OrganizationModule"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (hace falta owner/admin).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada o sin acceso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/modules/{key}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "key",
          "in": "path",
          "required": true,
          "description": "Clave del módulo (p. ej. `finance`).",
          "schema": {
            "type": "string"
          }
        }
      ],
      "put": {
        "operationId": "setOrganizationModule",
        "tags": [
          "billing"
        ],
        "summary": "Encender o apagar un módulo para toda la organización",
        "description": "Apagar hace tres cosas y solo tres: el módulo desaparece de la navegación, el API deja de aceptar sus peticiones (403 `feature_not_in_plan`) y los datos SIGUEN AHÍ. Volver a encenderlo lo devuelve tal cual estaba. Requiere owner/admin. Un módulo que no sea apagable responde 422 `module_not_switchable`; uno que el plan no incluya, 422 `module_not_available` — también al intentar encenderlo, de modo que este endpoint no puede usarse para saltarse el plan.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OrganizationModuleSet"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Estado del módulo tras el cambio.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OrganizationModule"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (hace falta owner/admin).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada o sin acceso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`module_not_switchable` (no es un módulo apagable) o `module_not_available` (el plan de la organización no lo incluye).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/billing/checkout-session": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "createBillingCheckoutSession",
        "tags": [
          "billing"
        ],
        "summary": "Crear una sesión de Stripe Checkout para subir de plan",
        "description": "Crea una sesión de Stripe Checkout (modo suscripción) para el plan indicado y devuelve la `url` a la que redirigir el navegador. Requiere rol admin+ en la organización. Responde `503 billing_not_configured` si Stripe no está configurado en el servidor.\n\nDATOS FISCALES OBLIGATORIOS: la organización debe tener `legal_name`, `tax_id` y `fiscal_address` rellenos (`PATCH /organizations/{org_id}`) o la sesión no se crea — `422 fiscal_data_required`, con los campos que faltan en el `message`. La factura de la suscripción los necesita, y pedirlos después de cobrar obliga a rectificar. Solo afecta a los planes de pago: el plan Free no pasa por Checkout (`422 plan_not_purchasable`).\n\nLa dirección de facturación y el NIF/VAT los recoge y valida además Stripe en su propio formulario: son los que acaban impresos en su factura.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CheckoutSessionCreate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "URL de la sesión de Checkout.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BillingRedirect"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (se requiere admin+) o usuario no miembro (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada o sin acceso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido, plan no comprable (`plan_not_purchasable`), faltan datos fiscales de la organización (`fiscal_data_required`) o el `promo_code` enviado no es utilizable (`promo_not_found`, `promo_expired`, `promo_exhausted`, `promo_plan_mismatch`, `promo_already_redeemed`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Stripe no configurado en el servidor (`billing_not_configured`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/billing/promo/{code}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "code",
          "in": "path",
          "required": true,
          "description": "Código promocional (sin distinguir mayúsculas).",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "resolveBillingPromoCode",
        "tags": [
          "billing"
        ],
        "summary": "Consultar qué concede un código promocional",
        "description": "Devuelve el beneficio del código para ESTA organización, para poder anunciarlo antes de pagar. Requiere rol admin+. Aplica las mismas validaciones que el checkout —vigencia, usos disponibles, plan y canje previo de la organización—, así que un código inservible se detecta aquí y no al pagar.",
        "responses": {
          "200": {
            "description": "Beneficio del código.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PromoCode"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (se requiere admin+).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada o sin acceso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Código inexistente o no disponible (`promo_not_found`), fuera de vigencia (`promo_expired`), sin usos (`promo_exhausted`), de otro plan (`promo_plan_mismatch`) o ya canjeado por esta organización (`promo_already_redeemed`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/billing/portal-session": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "createBillingPortalSession",
        "tags": [
          "billing"
        ],
        "summary": "Crear una sesión del Stripe Billing Portal",
        "description": "Crea una sesión del Stripe Billing Portal para que la organización gestione o cancele su suscripción, y devuelve la `url`. Requiere rol admin+. Responde `404` si la organización aún no tiene cliente de facturación (nunca inició un checkout) y `503 billing_not_configured` si Stripe no está configurado.",
        "responses": {
          "200": {
            "description": "URL del Billing Portal.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BillingRedirect"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (se requiere admin+) o usuario no miembro (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada, sin acceso, o sin cliente de facturación (`billing_customer_not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Stripe no configurado en el servidor (`billing_not_configured`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/billing/webhook": {
      "post": {
        "operationId": "stripeBillingWebhook",
        "tags": [
          "billing"
        ],
        "summary": "Webhook de eventos de Stripe (suscripciones)",
        "description": "Endpoint PÚBLICO (sin cookie de sesión) que recibe los eventos de Stripe. Verifica la cabecera `Stripe-Signature` con `STRIPE_WEBHOOK_SECRET` sobre el cuerpo crudo y aplica los cambios: `checkout.session.completed` activa el plan comprado (`organizations.plan`); `customer.subscription.updated` actualiza estado/plan; `customer.subscription.deleted` vuelve el plan a `free`. Responde `400 invalid_signature` si la firma no valida y `503 billing_not_configured` si Stripe no está configurado.",
        "security": [],
        "parameters": [
          {
            "name": "Stripe-Signature",
            "in": "header",
            "required": true,
            "description": "Firma HMAC del evento que envía Stripe.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "description": "Evento de Stripe (cuerpo crudo; la firma se verifica sobre estos bytes).",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Evento recibido y procesado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BillingWebhookAck"
                }
              }
            }
          },
          "400": {
            "description": "Firma inválida o payload no verificable (`invalid_signature`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Stripe no configurado en el servidor (`billing_not_configured`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/me/calendar-feed": {
      "get": {
        "operationId": "getCalendarFeed",
        "x-tool": {
          "name": "calendar_feed",
          "domain": "calendar",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "calendar"
        ],
        "summary": "Obtener (o crear) feed de calendario",
        "description": "Devuelve el token y la URL de suscripción del feed ICS personal. Si el usuario aún no tiene token se genera uno ahora (lazy). Requiere estar autenticado.",
        "responses": {
          "200": {
            "description": "Token e URL de suscripción del feed ICS.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CalendarFeed"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/calendar/{token}.ics": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token opaco del feed (obtenido en GET /me/calendar-feed).",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getCalendarIcs",
        "tags": [
          "calendar"
        ],
        "summary": "Descargar feed ICS (público)",
        "description": "Endpoint PÚBLICO (sin cookie/JWT) que devuelve el VCALENDAR en texto plano con VEVENTs para todas las tareas asignadas al usuario que tienen `due_date`, sus reuniones datadas y sus ausencias aprobadas. La URL tiene la forma `/api/v1/calendar/{token}.ics`; cualquier cliente de calendario puede suscribirse directamente.",
        "security": [],
        "responses": {
          "200": {
            "description": "Feed iCalendar (RFC 5545).",
            "content": {
              "text/calendar": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido o inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/calendar/events": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listCalendarEvents",
        "x-tool": {
          "name": "list_calendar_events",
          "domain": "calendar",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "calendar"
        ],
        "summary": "Eventos del calendario (festivos + ausencias)",
        "description": "Devuelve los eventos del calendario de la organización en el rango `[start, end]` (inclusive): los festivos de la organización (recurrentes expandidos a su fecha del rango) y las ausencias APROBADAS que solapan el rango. Pensado para pintar festivos y ausencias en el mismo calendario. El rango no puede exceder 366 días. Requiere member+.",
        "parameters": [
          {
            "name": "start",
            "in": "query",
            "required": true,
            "description": "Inicio del rango (inclusive, `YYYY-MM-DD`).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "end",
            "in": "query",
            "required": true,
            "description": "Fin del rango (inclusive, `YYYY-MM-DD`).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de eventos del calendario (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CalendarEvent"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Rango inválido (`end` < `start` o rango > 366 días).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/calendar/business-time/minutes": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "businessMinutesBetween",
        "tags": [
          "calendar"
        ],
        "summary": "Minutos laborables entre dos instantes",
        "description": "Devuelve los minutos LABORABLES transcurridos entre `start` y `end` según el calendario de la organización: su jornada semanal (`/workforce/schedule`), sus festivos (`/calendar/holidays`, con los recurrentes proyectados al año que corresponda) y su zona horaria. No cuenta noches, fines de semana ni festivos.\n\nEl cálculo se hace en hora local de la organización, así que los cambios de hora de marzo y octubre no lo desplazan. Si la organización no tiene jornada configurada se usa un respaldo de lunes a viernes de 09:00 a 18:00 y la respuesta lo indica en `schedule_source`.\n\nUn rango de duración cero devuelve `0`; un rango invertido (`end` antes que `start`) es un 422. El rango no puede exceder 1830 días. Requiere member+.",
        "parameters": [
          {
            "name": "start",
            "in": "query",
            "required": true,
            "description": "Instante inicial (ISO 8601; sin zona se interpreta como UTC).",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "end",
            "in": "query",
            "required": true,
            "description": "Instante final (ISO 8601; sin zona se interpreta como UTC).",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Minutos laborables del rango.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BusinessMinutes"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Rango invertido (`invalid_date_range`) o mayor de 1830 días (`range_too_large`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/calendar/business-time/due": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "businessTimeDue",
        "tags": [
          "calendar"
        ],
        "summary": "Vencimiento tras N minutos laborables",
        "description": "Devuelve el instante en el que se cumplen `minutes` minutos LABORABLES contados desde `start` (por defecto, ahora), según el calendario de la organización. Es el cálculo de un compromiso de servicio («4 horas laborables de respuesta») y sirve igual para estimar una fecha de entrega.\n\nSi el plazo se agota justo al cierre de la jornada, el vencimiento ES el cierre, no la apertura del día siguiente. Con `minutes = 0` el vencimiento es el propio `start`. Requiere member+.",
        "parameters": [
          {
            "name": "minutes",
            "in": "query",
            "required": true,
            "description": "Minutos laborables a sumar (p. ej. 240 = 4 horas laborables).",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "maximum": 100000
            }
          },
          {
            "name": "start",
            "in": "query",
            "required": false,
            "description": "Instante desde el que contar (ISO 8601; sin zona se interpreta como UTC). Por defecto, el instante actual del servidor.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Instante de vencimiento.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BusinessDue"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`minutes` fuera de rango (`validation_error`) o vencimiento más allá del horizonte de cálculo (`deadline_out_of_range`), lo que solo puede ocurrir con una jornada tan escasa que el plazo no quepa en dos años.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/calendar/holidays": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listHolidays",
        "x-tool": {
          "name": "list_holidays",
          "domain": "calendar",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "calendar"
        ],
        "summary": "Listar festivos de la organización",
        "description": "Lista de festivos de la organización ordenados por fecha. Requiere member+.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 500
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de festivos (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de festivos de la organización (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Holiday"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createHoliday",
        "x-tool": {
          "name": "create_holiday",
          "domain": "calendar",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "calendar"
        ],
        "summary": "Crear festivo",
        "description": "Crea un festivo en la organización. La fecha es única por organización. Requiere manager+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/HolidayCreateIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Festivo creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Holiday"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente; manager+ requerido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ya existe un festivo en esa fecha en la organización.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (validación fallida).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/calendar/holidays/import": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "importHolidays",
        "tags": [
          "calendar"
        ],
        "summary": "Importar festivos en bloque (CSV)",
        "description": "Crea festivos en bloque a partir de las filas de un CSV (`fecha`, `nombre`, `ambito`) ya parseadas por el cliente. Valida cada fila (fecha `YYYY-MM-DD`, nombre no vacío, ámbito `nacional`|`regional`|`local` con los sinónimos de cada país: `federal`, `state`, `autonomico`, `municipal`…) y es IDEMPOTENTE por `(organización, fecha)`: omite las fechas que ya existen. Devuelve un resumen `{created, skipped, errors}`. Requiere manager+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/HolidayImportIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Resumen de la importación (creados, omitidos, errores por fila).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HolidayImportResult"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente; manager+ requerido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (sin filas o más de 1000).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/calendar/holidays/{holiday_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "holiday_id",
          "in": "path",
          "required": true,
          "description": "UUID del festivo.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getHoliday",
        "x-tool": {
          "name": "get_holiday",
          "domain": "calendar",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "calendar"
        ],
        "summary": "Detalle de festivo",
        "responses": {
          "200": {
            "description": "Festivo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Holiday"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Festivo u organización inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateHoliday",
        "x-tool": {
          "name": "update_holiday",
          "domain": "calendar",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "calendar"
        ],
        "summary": "Actualizar festivo",
        "description": "Actualización parcial del festivo. Requiere manager+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/HolidayUpdateIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Festivo actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Holiday"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente; manager+ requerido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Festivo u organización inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ya existe un festivo en esa fecha en la organización.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteHoliday",
        "x-tool": {
          "name": "delete_holiday",
          "domain": "calendar",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "calendar"
        ],
        "summary": "Eliminar festivo",
        "description": "Elimina el festivo permanentemente. Requiere manager+.",
        "responses": {
          "204": {
            "description": "Festivo eliminado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente; manager+ requerido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Festivo u organización inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/notifications/unsubscribe": {
      "parameters": [
        {
          "name": "token",
          "in": "query",
          "required": true,
          "description": "Token firmado que identifica al usuario, la organización y la categoría.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "unsubscribeEmailCategory",
        "tags": [
          "notifications"
        ],
        "summary": "Darse de baja del email de una categoría",
        "description": "Se abre desde el enlace del propio correo, así que **no exige sesión**: la autorización es el token firmado. Da de baja SOLO el canal email y conserva in-app y web push — darse de baja del correo no debe dejar al usuario incomunicado dentro del producto.\n\nIdempotente: repetir la baja responde igual.",
        "security": [],
        "responses": {
          "200": {
            "description": "Baja aplicada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UnsubscribeResult"
                }
              }
            }
          },
          "400": {
            "description": "Token con firma inválida, manipulado, o de una categoría que ya no está en el registro (`bad_request`). Los dos casos son indistinguibles a propósito: el mensaje no dice cuál de los dos es.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Falta el parámetro `token` en la query (`validation_error`). Un token PRESENTE pero inválido es el 400 de arriba, no este.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "unsubscribeEmailCategoryPost",
        "tags": [
          "notifications"
        ],
        "summary": "Darse de baja del email de una categoría (One-Click)",
        "description": "Variante POST exigida por la RFC 8058 (`List-Unsubscribe-Post: List-Unsubscribe=One-Click`): Gmail y Outlook la invocan directamente desde su interfaz, sin abrir el navegador del usuario. Hace exactamente lo mismo que el GET.",
        "security": [],
        "responses": {
          "200": {
            "description": "Baja aplicada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UnsubscribeResult"
                }
              }
            }
          },
          "400": {
            "description": "Token con firma inválida, manipulado, o de una categoría que ya no está en el registro (`bad_request`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Falta el parámetro `token` en la query (`validation_error`). Un token PRESENTE pero inválido es el 400 de arriba, no este.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/copilot/chat/stream": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "copilotChatStream",
        "tags": [
          "copilot"
        ],
        "summary": "Conversar con Kern con streaming (SSE)",
        "description": "Mismo cuerpo, permisos (member+) y degradación que `POST /copilot/chat`, pero el texto se transmite según se genera en vez de esperar a la respuesta completa.\n\nResponde `text/event-stream` con tres tipos de evento: `tool` cada vez que se ejecuta una herramienta, `delta` por cada fragmento de texto, y `done` al terminar con `{reply, tool_calls, proposed_actions, conversation_id}` — el mismo objeto que devuelve el endpoint no-streaming.\n\nEl streaming token a token es SOLO con el proveedor Anthropic; con OpenRouter se cae al chat no-streaming y se emite un único `done`. Si el copiloto no está configurado responde `503 copilot_unavailable` ANTES de abrir el stream, para que el cliente no se quede esperando.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CopilotChatIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Stream de eventos SSE.",
            "content": {
              "text/event-stream": {
                "schema": {
                  "type": "string",
                  "description": "Secuencia `event: <tipo>\\ndata: <json>\\n\\n`. Ver la descripción de la operación para los tipos y su carga."
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin acceso a la organización o plan sin la función.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Límite de uso de Kern superado (`too_many_requests`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Copiloto no configurado (`copilot_unavailable`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/quotes/{quote_id}/pdf": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "quote_id",
          "in": "path",
          "required": true,
          "description": "UUID del presupuesto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "downloadQuotePdf",
        "tags": [
          "quotes"
        ],
        "summary": "Descargar el PDF de un presupuesto",
        "description": "Devuelve el PDF con la marca de la organización. Requiere ser miembro con acceso al presupuesto; un presupuesto de otra organización responde 404 (nunca se confirma que exista).",
        "responses": {
          "200": {
            "description": "PDF del presupuesto.",
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permisos sobre el presupuesto (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Presupuesto u organización no encontrados (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/quotes/{token}/pdf": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token de compartición del presupuesto.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "downloadPublicQuotePdf",
        "tags": [
          "quotes"
        ],
        "summary": "Descargar el PDF de un presupuesto compartido",
        "description": "Versión pública, sin sesión: la autorización es el propio token. Limitado por IP igual que el resto de enlaces públicos, y un token inválido, caducado o de una organización borrada responde 404 — el mismo código en los tres casos, para no confirmarle nada a un visitante anónimo.",
        "security": [],
        "responses": {
          "200": {
            "description": "PDF del presupuesto.",
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido, caducado o presupuesto inaccesible (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones desde esta IP (`too_many_requests`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/profiles/{user_id}": {
      "parameters": [
        {
          "name": "user_id",
          "in": "path",
          "required": true,
          "description": "UUID de la persona.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getPublicProfile",
        "tags": [
          "auth"
        ],
        "summary": "Ver el perfil social de una persona",
        "description": "Devuelve el perfil si su privacidad lo permite. **Se sirve sin sesión** para los perfiles públicos: si exigiéramos estar dentro, «público» significaría «público para usuarios de Projekt», que no es lo mismo.\n\nResponde **404** cuando no se puede ver —perfil privado, de organización ajena, o inexistente—: un 403 confirmaría que la cuenta existe y permitiría enumerar quién tiene cuenta.",
        "security": [],
        "responses": {
          "200": {
            "description": "Perfil visible.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicProfile"
                }
              }
            }
          },
          "404": {
            "description": "Perfil inexistente o no visible para quien mira (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/profiles/{user_id}/posts": {
      "parameters": [
        {
          "name": "user_id",
          "in": "path",
          "required": true,
          "description": "UUID de la persona.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "limit",
          "in": "query",
          "required": false,
          "schema": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100,
            "default": 20
          }
        },
        {
          "name": "offset",
          "in": "query",
          "required": false,
          "schema": {
            "type": "integer",
            "minimum": 0,
            "default": 0
          }
        }
      ],
      "get": {
        "operationId": "listProfilePosts",
        "tags": [
          "auth"
        ],
        "summary": "Publicaciones de un perfil",
        "description": "Las más recientes primero. Su visibilidad es la del PERFIL, no por publicación; si el perfil no es visible, 404.",
        "security": [],
        "responses": {
          "200": {
            "description": "Publicaciones del perfil.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/UserPost"
                  }
                }
              }
            }
          },
          "404": {
            "description": "Perfil inexistente o no visible (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/profiles/{user_id}/follow": {
      "parameters": [
        {
          "name": "user_id",
          "in": "path",
          "required": true,
          "description": "UUID de la persona a la que seguir.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "followProfile",
        "tags": [
          "auth"
        ],
        "summary": "Seguir a una persona",
        "description": "Relación ASIMÉTRICA: no hace falta que la otra parte acepte. Solo se puede seguir a quien deja ver su perfil (404 si no). **Idempotente**: seguir dos veces responde 204 igualmente.",
        "responses": {
          "204": {
            "description": "Ahora la sigues; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Perfil inexistente o no visible (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "No puedes seguirte a ti mismo (`cannot_follow_self`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "unfollowProfile",
        "tags": [
          "auth"
        ],
        "summary": "Dejar de seguir a una persona",
        "description": "Idempotente: si no la seguías, responde 204 igualmente.",
        "responses": {
          "204": {
            "description": "Ya no la sigues; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/me/posts": {
      "post": {
        "operationId": "createMyPost",
        "tags": [
          "auth"
        ],
        "summary": "Publicar en el perfil propio",
        "description": "Se escribe siempre en el perfil de la sesión, así que no hay ningún id que validar. Quién lo verá lo decide `profile_visibility`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UserPostCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Publicación creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UserPost"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido o publicación vacía (`empty_post`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/me/posts/{post_id}": {
      "parameters": [
        {
          "name": "post_id",
          "in": "path",
          "required": true,
          "description": "UUID de la publicación.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "deleteMyPost",
        "tags": [
          "auth"
        ],
        "summary": "Borrar una publicación propia",
        "description": "Borrado SUAVE, para que moderar no destruya la evidencia. Solo el autor (`403 not_post_author` en caso contrario — aquí sí es 403 y no 404: el autor ya sabe que la publicación existe, la está viendo).",
        "responses": {
          "204": {
            "description": "Publicación borrada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No eres el autor (`not_post_author`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Publicación no encontrada (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/project-folders": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listProjectFolders",
        "tags": [
          "projects"
        ],
        "summary": "Listar carpetas de proyectos",
        "description": "Carpetas de la organización con su recuento de proyectos, ordenadas por posición y, a igualdad, por nombre. Basta con ser miembro: quien trabaja necesita ver cómo está organizado el listado aunque no pueda tocarlo.\n\nFunción del plan Business en adelante; en el resto responde `403 feature_not_available`.",
        "responses": {
          "200": {
            "description": "Carpetas de la organización.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ProjectFolder"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Plan sin la función (`feature_not_available`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada o sin acceso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createProjectFolder",
        "tags": [
          "projects"
        ],
        "summary": "Crear una carpeta de proyectos",
        "description": "Requiere rol `admin`/`owner` y plan Business.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ProjectFolderCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Carpeta creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProjectFolder"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Plan sin la función (`feature_not_available`) o rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada o sin acceso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ya existe una carpeta con ese nombre (`folder_name_taken`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/project-folders/{folder_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "folder_id",
          "in": "path",
          "required": true,
          "description": "UUID de la carpeta.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "updateProjectFolder",
        "tags": [
          "projects"
        ],
        "summary": "Renombrar, recolorear o reordenar una carpeta",
        "description": "Requiere rol `admin`/`owner` y plan Business.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ProjectFolderUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Carpeta actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProjectFolder"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Plan sin la función (`feature_not_available`) o rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Carpeta u organización no encontradas (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ya existe una carpeta con ese nombre (`folder_name_taken`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteProjectFolder",
        "tags": [
          "projects"
        ],
        "summary": "Borrar una carpeta de proyectos",
        "description": "Borra la carpeta. Sus proyectos quedan SUELTOS (`folder_id` a `null`), nunca se borran: una carpeta es una etiqueta de organización, no un contenedor de propiedad. Requiere rol `admin`/`owner` y plan Business.",
        "responses": {
          "204": {
            "description": "Carpeta borrada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Plan sin la función (`feature_not_available`) o rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Carpeta u organización no encontradas (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/project-folders/{folder_id}/departments": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "folder_id",
          "in": "path",
          "required": true,
          "description": "UUID de la carpeta.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listProjectFolderDepartments",
        "tags": [
          "projects"
        ],
        "summary": "Listar los departamentos con acceso a una carpeta",
        "description": "Departamentos (M:N `project_folder_departments`) cuyos miembros ven la carpeta cuando es `restricted` — uno o VARIOS; el caller accede si pertenece a CUALQUIERA de ellos. Gestionar los permisos de una carpeta es cosa de `owner`/`admin`, igual que crearla o borrarla. Función del plan Business en adelante (`403 feature_not_available` en el resto).",
        "responses": {
          "200": {
            "description": "Departamentos con acceso a la carpeta (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Department"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Plan sin la función (`feature_not_available`) o rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Carpeta u organización no encontradas (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "put": {
        "operationId": "setProjectFolderDepartments",
        "tags": [
          "projects"
        ],
        "summary": "Fijar los departamentos con acceso a una carpeta",
        "description": "Reemplaza el conjunto COMPLETO de departamentos que ven la carpeta restringida (semántica PUT: el set queda exactamente como los ids enviados; lista vacía = solo owner/admin). Cada `department_id` debe pertenecer a la MISMA organización (`422 department_not_found` si alguno no). Requiere rol `owner`/`admin` y plan Business.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "title": "ProjectFolderDepartmentsIn",
                "required": [
                  "department_ids"
                ],
                "properties": {
                  "department_ids": {
                    "type": "array",
                    "description": "UUIDs de los departamentos (de la MISMA org) que verán la carpeta. Duplicados se ignoran; el orden no es significativo.",
                    "items": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Departamentos con acceso tras la actualización.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Department"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Plan sin la función (`feature_not_available`) o rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Carpeta u organización no encontradas (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Algún `department_id` no pertenece a la organización (`department_not_found`) o cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/project-folders/{folder_id}/pin": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "folder_id",
          "in": "path",
          "required": true,
          "description": "UUID de la carpeta.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "put": {
        "operationId": "pinProjectFolder",
        "tags": [
          "projects"
        ],
        "summary": "Fijar la carpeta como filtro por defecto",
        "description": "Fija la carpeta para QUIEN LLAMA: al entrar en Proyectos, el listado aparecerá filtrado por ella en vez de por «Todas». La preferencia es por usuario y por organización (se guarda en su membresía), así que fijar no cambia nada para el resto del equipo y cada organización tiene la suya.\n\nSolo puede haber UNA carpeta fijada a la vez: fijar otra sustituye la anterior sin necesidad de desfijarla. Idempotente. Basta con ser miembro y poder VER la carpeta — una carpeta `restricted` sin acceso responde `404`, igual que si no existiera. Función del plan Business en adelante (`403 feature_not_available` en el resto).",
        "responses": {
          "200": {
            "description": "Carpeta fijada (`pinned: true`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProjectFolder"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Plan sin la función (`feature_not_available`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Carpeta u organización no encontradas, o carpeta sin acceso para el caller (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "unpinProjectFolder",
        "tags": [
          "projects"
        ],
        "summary": "Desfijar la carpeta",
        "description": "Quita la carpeta fijada de QUIEN LLAMA: el listado de proyectos vuelve a abrirse en «Todas». Idempotente — desfijar una carpeta que no estaba fijada responde `204` igualmente y no toca la que sí lo esté.",
        "responses": {
          "204": {
            "description": "Preferencia limpiada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Plan sin la función (`feature_not_available`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Carpeta u organización no encontradas (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listProjects",
        "x-tool": {
          "name": "list_projects",
          "domain": "pm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "projects"
        ],
        "summary": "Listar proyectos",
        "description": "Proyectos de la organización; requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Lista de proyectos (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Project"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createProject",
        "x-tool": {
          "name": "create_project",
          "domain": "pm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "projects"
        ],
        "summary": "Crear proyecto",
        "description": "Crea un proyecto en la organización (status inicial `active`).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "description": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Opcional; puede omitirse o enviarse `null`."
                  },
                  "logo_url": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "URL de logotipo opcional; puede omitirse o enviarse `null`."
                  },
                  "key": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Acrónimo/clave del proyecto (prefijo de las referencias, p.ej. \"PJKT-123\"). Opcional: 2-6 caracteres alfanuméricos ASCII que empiezan por letra; si se omite se auto-deriva del nombre.",
                    "examples": [
                      "PJKT"
                    ]
                  },
                  "department_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Departamento (de la MISMA organización) al que se asigna el proyecto. Opcional; debe pertenecer a la organización o se rechaza con 422 (`department_not_found`)."
                  },
                  "client_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Cliente (de la MISMA organización) al que se vincula el proyecto. Opcional; debe pertenecer a la organización o se rechaza con 422 (`client_not_found`)."
                  },
                  "parent_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Proyecto del que colgará éste como SUBPROYECTO. Opcional. Debe ser de la misma organización y visible para quien crea (422 `parent_project_not_found` si no).\n\nSe puede ANIDAR sin tope: un subproyecto puede tener los suyos (ACRO → ACRO-api → los subsistemas de ACRO-api). Lo único que se rechaza es cerrar el círculo — colgar un proyecto de uno de sus propios descendientes — con 422 `subproject_cycle`.\n\nEl subproyecto hereda del padre la visibilidad, los departamentos y los miembros explícitos, y el cliente y el departamento si no se mandan. La herencia es solo AL NACER y de un salto: un nieto hereda del intermedio, no del abuelo."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Proyecto creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Project"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`) o la organización ya agotó el cupo de proyectos de su plan (`plan_limit_reached`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/trash": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listProjectTrash",
        "x-tool": {
          "name": "list_project_trash",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "projects"
        ],
        "summary": "Listar proyectos en papelera",
        "description": "Proyectos borrados (soft-delete) de la organización, ordenados por fecha de borrado más reciente. Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Lista de proyectos en papelera (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Project"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/activity": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getProjectsActivity",
        "x-tool": {
          "name": "get_projects_activity",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "projects"
        ],
        "summary": "Actividad reciente de todos los proyectos",
        "description": "One row per active project of the organization, with one bucket per day counting the tasks updated that day. It exists so the project list can draw its sparklines in ONE request: asking each project for its tasks means one request per row, and with twenty projects the list finishes painting before the first answer arrives.\n\nProjects with no activity are included, with all buckets at zero: a sparkline needs the shape, and leaving them out would make the caller guess whether the project is quiet or the row is still loading. Requires being a member.",
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "required": false,
            "description": "How many daily buckets to return, today included.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 90,
              "default": 7
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Una fila por proyecto activo (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ProjectActivity"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Fuera de rango: `days` es entero entre 1 y 90.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/by-key/{key}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "key",
          "in": "path",
          "required": true,
          "description": "Clave del proyecto (case-insensitive, e.g. `PJKT`).",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getProjectByKey",
        "x-tool": {
          "name": "get_project_by_key",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "projects"
        ],
        "summary": "Resolver proyecto por clave",
        "description": "Devuelve el proyecto cuya clave coincida (insensible a mayúsculas) dentro de la organización. Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Proyecto encontrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Project"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getProject",
        "x-tool": {
          "name": "get_project",
          "domain": "pm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "projects"
        ],
        "summary": "Detalle de proyecto",
        "description": "Devuelve el proyecto si pertenece a la organización y el usuario es miembro.",
        "responses": {
          "200": {
            "description": "Proyecto.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Project"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateProject",
        "x-tool": {
          "name": "update_project",
          "domain": "pm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "projects"
        ],
        "summary": "Actualizar proyecto",
        "description": "Actualización parcial; todos los campos son opcionales.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "description": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra la descripción."
                  },
                  "logo_url": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra el logotipo."
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "active",
                      "archived"
                    ]
                  },
                  "board_swimlane": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Agrupación swimlane del tablero; `null` desactiva. Solo owner/admin.",
                    "enum": [
                      "none",
                      "assignee",
                      "priority",
                      "type"
                    ]
                  },
                  "department_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Reasigna el proyecto a un departamento de la MISMA organización; `null` lo desvincula. Un departamento de otra organización se rechaza con 422 (`department_not_found`)."
                  },
                  "client_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Reasigna el proyecto a un cliente de la MISMA organización; `null` lo desvincula. Un cliente de otra organización se rechaza con 422 (`client_not_found`)."
                  },
                  "folder_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Mueve el proyecto a una carpeta de la MISMA organización; `null` lo saca de ella. Una carpeta de otra organización se rechaza con 422 (`folder_not_found`). Mover un proyecto NO cambia quién puede verlo."
                  },
                  "parent_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Cuelga el proyecto de otro como subproyecto, o `null` lo vuelve raíz. El padre debe ser de la misma organización y visible (422 `parent_project_not_found`); ni el padre puede ser un subproyecto ni éste tener subproyectos (422 `subproject_depth`). Cambiar de padre NO cambia quién puede verlo: la herencia de permisos es solo al nacer."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Proyecto actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Project"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteProject",
        "tags": [
          "projects"
        ],
        "summary": "Borrar proyecto (soft-delete)",
        "description": "Mueve el proyecto a la papelera (soft-delete). El proyecto deja de aparecer en listados activos pero puede restaurarse. Requiere ser miembro.\n\nSOLO SESIÓN DE NAVEGADOR: con credenciales de API (`Authorization: Bearer` — PAT `pjk_live_…` u OAuth `pjk_oat_…`) la petición se rechaza con 403 `interactive_session_required`. Esas credenciales viven desatendidas en scripts, aplicaciones de terceros y el conector MCP, y un borrado saca de circulación tareas, tiempos y adjuntos sin nadie delante; hazlo desde la aplicación web.",
        "responses": {
          "204": {
            "description": "Proyecto movido a papelera; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Petición sin sesión de navegador (`interactive_session_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/restore": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto en papelera.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "restoreProject",
        "x-tool": {
          "name": "restore_project",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "projects"
        ],
        "summary": "Restaurar proyecto desde papelera",
        "description": "Saca el proyecto de la papelera (limpia `deleted_at`). El proyecto vuelve a aparecer en los listados activos. Requiere ser miembro. Como los proyectos en papelera NO ocupan cupo del plan, restaurar vuelve a ocuparlo y puede devolver `plan_limit_reached`.",
        "responses": {
          "200": {
            "description": "Proyecto restaurado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Project"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permiso para editar proyectos, o el plan ya está en su límite de proyectos vivos (`plan_limit_reached`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/purge": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto a eliminar permanentemente.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "purgeProject",
        "tags": [
          "projects"
        ],
        "summary": "Eliminar proyecto permanentemente (purge)",
        "description": "Elimina el proyecto de forma permanente (hard delete). Solo funciona con proyectos ya en papelera. Requiere rol `admin` o superior.\n\nSOLO SESIÓN DE NAVEGADOR: con credenciales de API (`Authorization: Bearer` — PAT `pjk_live_…` u OAuth `pjk_oat_…`) la petición se rechaza con 403 `interactive_session_required`, incluso siendo `owner`. El borrado es irreversible y esas credenciales viven desatendidas.",
        "responses": {
          "204": {
            "description": "Proyecto eliminado permanentemente; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `admin` o `owner` (`forbidden`) — o petición sin sesión de navegador (`interactive_session_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/visibility": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "put": {
        "operationId": "setProjectVisibility",
        "tags": [
          "projects"
        ],
        "summary": "Fijar visibilidad del proyecto",
        "description": "Cambia la visibilidad del proyecto entre `organization` (visible a toda la org) y `restricted` (solo owner/admin, miembros explícitos o por departamento). Requiere rol `admin` o `manager`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "visibility"
                ],
                "properties": {
                  "visibility": {
                    "type": "string",
                    "enum": [
                      "organization",
                      "restricted"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Proyecto con la visibilidad actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Project"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `manager` o superior (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/visibility-departments": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listProjectVisibilityDepartments",
        "tags": [
          "projects"
        ],
        "summary": "Listar departamentos de visibilidad del proyecto",
        "description": "Departamentos (M:N `project_visibility_departments`) cuyos miembros pueden ver este proyecto cuando es `restricted` — uno o VARIOS. Es la cara \"proyecto\" de la MISMA fuente de verdad que `GET /departments/{department_id}/visibility-projects`. Cada elemento incluye el `access_level` (`read` | `write`) que esa asociación concede. Requiere rol `admin` o `manager`.",
        "responses": {
          "200": {
            "description": "Departamentos de visibilidad del proyecto (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ProjectVisibilityDepartment"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `manager` o superior (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "put": {
        "operationId": "setProjectVisibilityDepartments",
        "tags": [
          "projects"
        ],
        "summary": "Fijar los departamentos de visibilidad del proyecto",
        "description": "Reemplaza el conjunto COMPLETO de departamentos que pueden ver el proyecto restringido (semántica PUT: el set queda exactamente como los ids enviados; lista vacía = sin departamentos vinculados). Cada `department_id` debe pertenecer a la MISMA organización (`422 department_not_found` si alguno no). Requiere rol `admin` o `manager`.\n\nDos formas de enviar el set, mutuamente excluyentes (enviar ambas o ninguna → `422 validation_error`): `departments` (con nivel de acceso por departamento) o `department_ids` (forma histórica; equivale a `write` para todos).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "departments": {
                    "type": "array",
                    "description": "Departamentos (de la MISMA org) que podrán ver el proyecto, cada uno con su nivel de acceso. Duplicados por `department_id` se ignoran (gana el primero); el orden no es significativo.",
                    "items": {
                      "type": "object",
                      "title": "ProjectVisibilityDepartmentIn",
                      "required": [
                        "department_id"
                      ],
                      "properties": {
                        "department_id": {
                          "type": "string",
                          "format": "uuid"
                        },
                        "access_level": {
                          "allOf": [
                            {
                              "$ref": "#/components/schemas/ProjectAccessLevel"
                            }
                          ],
                          "default": "write"
                        }
                      }
                    }
                  },
                  "department_ids": {
                    "type": "array",
                    "description": "Forma histórica (equivale a `departments` con `access_level: write` para todos). UUIDs de los departamentos (de la MISMA org) que podrán ver el proyecto. Duplicados se ignoran; el orden no es significativo.",
                    "items": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Departamentos de visibilidad tras la actualización.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ProjectVisibilityDepartment"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `manager` o superior (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Algún `department_id` no pertenece a la organización (`department_not_found`), se enviaron `departments` y `department_ids` a la vez o ninguno de los dos, o cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/announcements": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listProjectAnnouncements",
        "tags": [
          "projects"
        ],
        "summary": "Listar avisos del proyecto",
        "description": "Avisos del proyecto, los fijados primero y luego por fecha descendente. Requiere poder VER el proyecto (404 homogéneo si no).",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Máximo de avisos a devolver (1..100, por defecto 50).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Desplazamiento de paginación.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Avisos del proyecto (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ProjectAnnouncement"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o proyecto no visible para el caller (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createProjectAnnouncement",
        "tags": [
          "projects"
        ],
        "summary": "Publicar un aviso en el proyecto",
        "description": "Publica un aviso y lo notifica a la audiencia del proyecto (seguidores que hoy pueden verlo), respetando las preferencias de cada usuario. Requiere ESCRITURA sobre el proyecto: un departamento con acceso de solo lectura recibe `403 read_only_project`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "title": "ProjectAnnouncementCreateIn",
                "required": [
                  "title"
                ],
                "properties": {
                  "title": {
                    "type": "string",
                    "maxLength": 200
                  },
                  "body": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "link": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "maxLength": 512,
                    "description": "Enlace opcional; si viene, es el deep-link que abre la notificación push."
                  },
                  "pinned": {
                    "type": "boolean",
                    "default": false
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Aviso publicado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProjectAnnouncement"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permiso de escritura sobre el proyecto (`read_only_project`) o rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o proyecto no visible para el caller (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/announcements/{announcement_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "announcement_id",
          "in": "path",
          "required": true,
          "description": "UUID del aviso.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "deleteProjectAnnouncement",
        "tags": [
          "projects"
        ],
        "summary": "Borrar un aviso del proyecto",
        "description": "Borra el aviso. Solo su AUTOR o un `admin`/`owner` de la organización; el resto recibe `403`.",
        "responses": {
          "204": {
            "description": "Aviso borrado."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es el autor ni admin/owner (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Aviso, proyecto u organización inexistente, o proyecto no visible (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/followers": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listProjectFollowers",
        "tags": [
          "projects"
        ],
        "summary": "Listar seguidores del proyecto",
        "description": "Usuarios que siguen el proyecto (reciben sus avisos), incluidos los silenciados — el campo `muted` los distingue. Requiere rol `manager` o superior; un member consulta su propio estado en `.../followers/me`.",
        "responses": {
          "200": {
            "description": "Seguidores del proyecto (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ProjectFollower"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `manager` o superior (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o proyecto no visible para el caller (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/followers/me": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getProjectFollowState",
        "tags": [
          "projects"
        ],
        "summary": "Mi estado de seguimiento del proyecto",
        "description": "Estado del usuario autenticado respecto a los avisos de este proyecto. Requiere poder VER el proyecto (404 homogéneo si no).",
        "responses": {
          "200": {
            "description": "Estado de seguimiento.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProjectFollowState"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o proyecto no visible para el caller (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "put": {
        "operationId": "setProjectFollowState",
        "tags": [
          "projects"
        ],
        "summary": "Seguir o silenciar el proyecto",
        "description": "Activa o silencia los avisos de este proyecto para el usuario autenticado. Idempotente. `following: false` NO borra el vínculo: lo marca como silenciado, para que el auto-follow (asignación, comentario, alta como miembro) no reactive lo que el usuario apagó a propósito. Requiere poder VER el proyecto.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "title": "ProjectFollowStateIn",
                "required": [
                  "following"
                ],
                "properties": {
                  "following": {
                    "type": "boolean",
                    "description": "`true` = recibir avisos; `false` = silenciar."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Estado de seguimiento tras el cambio.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProjectFollowState"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o proyecto no visible para el caller (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/members": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listProjectMembers",
        "tags": [
          "projects"
        ],
        "summary": "Listar miembros explícitos del proyecto",
        "description": "Usuarios con acceso explícito al proyecto (relevante en proyectos `restricted`). Requiere rol `admin` o `manager`.",
        "responses": {
          "200": {
            "description": "Miembros del proyecto (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ProjectMember"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `manager` o superior (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "addProjectMember",
        "tags": [
          "projects"
        ],
        "summary": "Añadir miembro explícito al proyecto",
        "description": "Concede acceso explícito a un usuario. El `user_id` debe pertenecer a la MISMA organización (`422 user_not_member` si no). Requiere rol `admin` o `manager`. `409 member_already_added` si ya era miembro.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "user_id"
                ],
                "properties": {
                  "user_id": {
                    "type": "string",
                    "format": "uuid"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Miembro añadido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProjectMember"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `manager` o superior (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "El usuario ya es miembro del proyecto (`member_already_added`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`user_id` no es miembro de la organización (`user_not_member`), o cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/members/{user_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "user_id",
          "in": "path",
          "required": true,
          "description": "UUID del usuario miembro a quitar.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "removeProjectMember",
        "tags": [
          "projects"
        ],
        "summary": "Quitar miembro explícito del proyecto",
        "description": "Revoca el acceso explícito de un usuario al proyecto. Requiere rol `admin` o `manager`. `404` si el usuario no era miembro.",
        "responses": {
          "204": {
            "description": "Miembro quitado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `manager` o superior (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto/organización inexistente, usuario no miembro del proyecto, o usuario no miembro de la org (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/tasks": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listTasks",
        "x-tool": [
          {
            "name": "list_tasks",
            "domain": "pm",
            "profile": "all",
            "sensitive": false
          },
          {
            "name": "list_issues",
            "domain": "pm",
            "profile": "core",
            "sensitive": false
          }
        ],
        "tags": [
          "tasks"
        ],
        "summary": "Listar tareas",
        "description": "Tareas del proyecto; requiere ser miembro de la organización. Acepta filtros, búsqueda de texto y orden server-side, todos opcionales y retrocompatibles (sin parámetros = comportamiento anterior). `X-Total-Count` refleja el total **filtrado** (sin paginar).",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filtra por status exacto (los 4 base o un slug personalizado de tablero).",
            "schema": {
              "type": "string",
              "pattern": "^[a-z0-9_]+$",
              "maxLength": 20
            }
          },
          {
            "name": "priority",
            "in": "query",
            "required": false,
            "description": "Filtra por prioridad.",
            "schema": {
              "type": "string",
              "enum": [
                "low",
                "medium",
                "high",
                "urgent"
              ]
            }
          },
          {
            "name": "type",
            "in": "query",
            "required": false,
            "description": "Filtra por tipo de trabajo (PM).",
            "schema": {
              "type": "string",
              "enum": [
                "epic",
                "story",
                "task",
                "bug",
                "spike",
                "chore"
              ]
            }
          },
          {
            "name": "assignee_id",
            "in": "query",
            "required": false,
            "description": "Filtra por asignado (UUID de un miembro de la organización).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "created_by",
            "in": "query",
            "required": false,
            "description": "Filtra por creador (UUID de un miembro de la organización).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "sprint_id",
            "in": "query",
            "required": false,
            "description": "Filtra por sprint (UUID de un sprint del proyecto).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "tag_id",
            "in": "query",
            "required": false,
            "description": "Filtra por etiqueta; repetible. Devuelve tareas que tengan AL MENOS una de las etiquetas indicadas.",
            "schema": {
              "type": "array",
              "items": {
                "type": "string",
                "format": "uuid"
              }
            }
          },
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Búsqueda de texto (case-insensitive) sobre título y descripción.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "due_before",
            "in": "query",
            "required": false,
            "description": "Solo tareas con `due_date` ≤ esta fecha (ISO `YYYY-MM-DD`); excluye las sin fecha.",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "due_after",
            "in": "query",
            "required": false,
            "description": "Solo tareas con `due_date` ≥ esta fecha (ISO `YYYY-MM-DD`); excluye las sin fecha.",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "parent_id",
            "in": "query",
            "required": false,
            "description": "Filtra por tarea padre (subtareas directas de este id).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "root_only",
            "in": "query",
            "required": false,
            "description": "Si `true`, solo tareas raíz (sin `parent_id`). Ignorado si se envía `parent_id`.",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Campo de ordenación. Por defecto `created`.",
            "schema": {
              "type": "string",
              "enum": [
                "created",
                "updated",
                "due",
                "priority",
                "number",
                "title"
              ],
              "default": "created"
            }
          },
          {
            "name": "order",
            "in": "query",
            "required": false,
            "description": "Sentido de la ordenación. Por defecto `asc`.",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ],
              "default": "asc"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de tareas (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de tareas que cumplen los filtros (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Task"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createTask",
        "x-tool": {
          "name": "create_issue",
          "domain": "pm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Crear tarea",
        "description": "Crea una tarea en el proyecto (status inicial `todo`).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "title"
                ],
                "properties": {
                  "title": {
                    "type": "string"
                  },
                  "description": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Opcional; puede omitirse o enviarse `null`."
                  },
                  "priority": {
                    "type": "string",
                    "description": "Opcional.",
                    "enum": [
                      "low",
                      "medium",
                      "high",
                      "urgent"
                    ]
                  },
                  "type": {
                    "type": "string",
                    "description": "Tipo de trabajo (PM); opcional, por defecto `task`.",
                    "enum": [
                      "epic",
                      "story",
                      "task",
                      "bug",
                      "spike",
                      "chore"
                    ]
                  },
                  "assignee_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Miembro de la organización al que asignar la tarea; opcional (`null` u omitido = sin asignar). Si el usuario no es miembro de la organización, `validation_error`."
                  },
                  "story_points": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "Estimación en puntos; opcional (`null` = sin estimar)."
                  },
                  "estimated_hours": {
                    "type": [
                      "string",
                      "number",
                      "null"
                    ],
                    "description": "Estimación en horas (`DECIMAL(6,2)`); opcional (`null` = sin estimar)."
                  },
                  "start_date": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date",
                    "description": "Fecha de inicio opcional (ISO `YYYY-MM-DD`); puede omitirse o enviarse `null`."
                  },
                  "due_date": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date",
                    "description": "Fecha de vencimiento opcional (ISO `YYYY-MM-DD`); puede omitirse o enviarse `null`."
                  },
                  "due_at": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date-time",
                    "description": "Vencimiento CON HORA (ISO 8601; sin zona se interpreta como UTC). Si se envía sin `due_date`, esta se deriva de su fecha local en la zona de la organización."
                  },
                  "completed_date": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date",
                    "description": "Fecha de finalización real (ISO `YYYY-MM-DD`); normalmente se omite y se autoasigna al pasar a `done`."
                  },
                  "sprint_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Sprint del mismo proyecto al que asociar la tarea; opcional (`null` = sin sprint). Si el sprint no es del proyecto, `validation_error`."
                  },
                  "parent_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Tarea padre del mismo proyecto; `null` (u omitido) = tarea raíz. Si la tarea padre es de otro proyecto o crea un ciclo, `validation_error`."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Tarea creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Task"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/tasks/search": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "searchTasks",
        "x-tool": {
          "name": "search_tasks",
          "domain": "pm",
          "profile": "all",
          "sensitive": false,
          "muta": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Ejecutar un filtro de tareas",
        "description": "Ejecuta un filtro sobre las tareas del proyecto y devuelve la página de resultados (mismos shapes y orden que `listTasks`). El filtro se indica de una de dos formas mutuamente excluyentes: `filter_id` (ejecuta un `saved_filter` guardado de entidad `tasks` propiedad del usuario) o `query` (un blob inline con el mismo shape que persiste `saved_filters`). La paginación (`limit`/`offset`) va dentro del blob. `X-Total-Count` refleja el total filtrado (sin paginar).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "filter_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "UUID de un `saved_filter` (entidad `tasks`) del usuario. Excluye `query`. `404` si no existe o no es del usuario."
                  },
                  "query": {
                    "type": [
                      "object",
                      "null"
                    ],
                    "description": "Blob de filtro inline (mismo shape que guarda `saved_filters`). Excluye `filter_id`. Se ignoran las CLAVES desconocidas, no los valores inválidos: un valor fuera de los enums de `priority`, `type`, `sort` u `order` es `422`, nunca un filtro que se acepta y no filtra.",
                    "properties": {
                      "status": {
                        "type": "string",
                        "pattern": "^[a-z0-9_]+$",
                        "maxLength": 20
                      },
                      "priority": {
                        "type": "string",
                        "enum": [
                          "low",
                          "medium",
                          "high",
                          "urgent"
                        ]
                      },
                      "type": {
                        "type": "string",
                        "enum": [
                          "epic",
                          "story",
                          "task",
                          "bug",
                          "spike",
                          "chore"
                        ]
                      },
                      "assignee_id": {
                        "type": "string",
                        "format": "uuid"
                      },
                      "created_by": {
                        "type": "string",
                        "format": "uuid"
                      },
                      "sprint_id": {
                        "type": "string",
                        "format": "uuid"
                      },
                      "tag_ids": {
                        "type": "array",
                        "items": {
                          "type": "string",
                          "format": "uuid"
                        }
                      },
                      "q": {
                        "type": "string"
                      },
                      "due_before": {
                        "type": "string",
                        "format": "date"
                      },
                      "due_after": {
                        "type": "string",
                        "format": "date"
                      },
                      "parent_id": {
                        "type": "string",
                        "format": "uuid"
                      },
                      "root_only": {
                        "type": "boolean"
                      },
                      "sort": {
                        "type": "string",
                        "enum": [
                          "created",
                          "updated",
                          "due",
                          "priority",
                          "number",
                          "title"
                        ]
                      },
                      "order": {
                        "type": "string",
                        "enum": [
                          "asc",
                          "desc"
                        ]
                      },
                      "limit": {
                        "type": "integer",
                        "minimum": 1,
                        "maximum": 200
                      },
                      "offset": {
                        "type": "integer",
                        "minimum": 0
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Página de tareas que cumplen el filtro (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de tareas que cumplen el filtro (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Task"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto/organización inexistente, usuario no miembro, o `filter_id` no encontrado (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Body inválido (ni `filter_id` ni `query`, ambos, o blob malformado) (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/tasks/bulk": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "bulkTaskOperation",
        "x-tool": {
          "name": "bulk_update_tasks",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Operación en lote sobre tareas",
        "description": "Aplica una operación a varias tareas del proyecto en una sola transacción. Valida que todas las tareas sean del proyecto y la organización antes de tocarlas; las que no existan (o sean de otra org/proyecto) se devuelven en `errors` con motivo `not_found`, sin afectar al resto. Máximo 200 ids. Operación de reorganización masiva deliberada: NO dispara automatizaciones ni emails, ni aplica límites WIP ni el workflow de columnas. Requiere ser miembro. El valor de la operación va en el campo correspondiente según `op`. El valor se valida UNA vez y de fallar es `422` sin tocar nada; lo que solo puede fallar POR TAREA (hoy: el ciclo de `set_parent`) va a `errors` con su motivo y no aborta el resto — igual que `not_found`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "task_ids",
                  "op"
                ],
                "properties": {
                  "task_ids": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 200,
                    "description": "UUIDs de las tareas a modificar (máx. 200; se deduplican).",
                    "items": {
                      "type": "string",
                      "format": "uuid"
                    }
                  },
                  "op": {
                    "type": "string",
                    "description": "Operación a aplicar a todas las tareas del lote.",
                    "enum": [
                      "set_status",
                      "set_assignee",
                      "set_sprint",
                      "set_parent",
                      "set_priority",
                      "add_tags",
                      "remove_tags",
                      "delete"
                    ]
                  },
                  "status": {
                    "type": "string",
                    "pattern": "^[a-z0-9_]+$",
                    "maxLength": 20,
                    "description": "Requerido para `op=set_status`. Nuevo status (base o slug personalizado)."
                  },
                  "assignee_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Para `op=set_assignee`. UUID de un miembro de la organización; `null` desasigna. `422` si no es miembro."
                  },
                  "sprint_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Para `op=set_sprint`. UUID de un sprint del proyecto; `null` saca del sprint. `422` si el sprint no es del proyecto."
                  },
                  "parent_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Para `op=set_parent`. UUID de la tarea que pasa a ser padre de todas las del lote; `null` las desvincula (dejan de ser subtareas). El padre se valida UNA vez contra la org Y el proyecto: si no es una tarea del mismo proyecto, `422` y no se toca nada. El ciclo se comprueba POR TAREA, porque depende de cada una: la tarea a la que se le pediría colgar de sí misma o de un descendiente suyo sale en `errors` con motivo `would_create_cycle` y las demás se aplican igual."
                  },
                  "priority": {
                    "type": "string",
                    "enum": [
                      "low",
                      "medium",
                      "high",
                      "urgent"
                    ],
                    "description": "Requerido para `op=set_priority`."
                  },
                  "tag_ids": {
                    "type": "array",
                    "description": "Requerido para `op=add_tags`/`remove_tags`. UUIDs de etiquetas de la organización a añadir o quitar. `422` si alguna no es de la org.",
                    "items": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Resultado honesto de la operación en lote.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaskBulkResult"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Body inválido, `op` sin su valor, o valor inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/tasks/by-number/{number}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "number",
          "in": "path",
          "required": true,
          "description": "Número secuencial de la tarea dentro del proyecto.",
          "schema": {
            "type": "integer",
            "minimum": 1
          }
        }
      ],
      "get": {
        "operationId": "getTaskByNumber",
        "x-tool": {
          "name": "get_task_by_number",
          "domain": "pm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Resolver tarea por número",
        "description": "Devuelve la tarea cuyo número secuencial coincida dentro del proyecto. Permite resolver una referencia `{KEY}-{number}` si ya se tiene el project_id.",
        "responses": {
          "200": {
            "description": "Tarea.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Task"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Tarea, proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/tasks/{task_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "description": "UUID de la tarea.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getTask",
        "x-tool": {
          "name": "get_task",
          "domain": "pm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Detalle de tarea",
        "description": "Devuelve la tarea si pertenece al proyecto y a la organización, y el usuario es miembro.",
        "responses": {
          "200": {
            "description": "Tarea.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Task"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Tarea, proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateTask",
        "x-tool": {
          "name": "update_issue",
          "domain": "pm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Actualizar tarea",
        "description": "Actualización parcial; todos los campos son opcionales.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "title": {
                    "type": "string"
                  },
                  "description": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra la descripción."
                  },
                  "status": {
                    "type": "string",
                    "pattern": "^[a-z0-9_]+$",
                    "maxLength": 20,
                    "description": "Nuevo estado. Admite los 4 base y estados PERSONALIZADOS de columna. El movimiento respeta el workflow del tablero (no se permite saltar columnas hacia adelante; `422` si es inválido)."
                  },
                  "priority": {
                    "type": "string",
                    "enum": [
                      "low",
                      "medium",
                      "high",
                      "urgent"
                    ]
                  },
                  "type": {
                    "type": "string",
                    "description": "Tipo de trabajo (PM); no admite `null`.",
                    "enum": [
                      "epic",
                      "story",
                      "task",
                      "bug",
                      "spike",
                      "chore"
                    ]
                  },
                  "assignee_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "UUID de un miembro de la organización; `null` desasigna."
                  },
                  "sprint_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Sprint del mismo proyecto; `null` saca la tarea del sprint. Omitir no lo toca. Si el sprint no es del proyecto, `validation_error`."
                  },
                  "story_points": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "Estimación en puntos; `null` la limpia. Omitir no la toca."
                  },
                  "estimated_hours": {
                    "type": [
                      "string",
                      "number",
                      "null"
                    ],
                    "description": "Estimación en horas (`DECIMAL(6,2)`); `null` la limpia. Omitir no la toca."
                  },
                  "start_date": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date",
                    "description": "Fecha de inicio (ISO `YYYY-MM-DD`); `null` la limpia. Omitir no la toca."
                  },
                  "due_date": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date",
                    "description": "Fecha de vencimiento (ISO `YYYY-MM-DD`); `null` la limpia. Omitir no la toca."
                  },
                  "due_at": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date-time",
                    "description": "Vencimiento CON HORA (ISO 8601; sin zona se interpreta como UTC); `null` lo limpia y limpia también la fecha derivada. Omitir no lo toca."
                  },
                  "completed_date": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date",
                    "description": "Fecha de finalización (ISO `YYYY-MM-DD`); `null` la limpia. Omitir no la toca. Se autoasigna a hoy al pasar a `done` si está vacía y no se envía en la misma petición."
                  },
                  "parent_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Tarea padre del mismo proyecto; `null` desvincula de la jerarquía. Omitir no lo toca. Si crea un ciclo o es de otro proyecto, `validation_error`."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Tarea actualizada.",
            "headers": {
              "X-Open-Blockers": {
                "description": "Solo al CERRAR la tarea (`status: done`) y solo si es mayor que 0: cuántas tareas que la BLOQUEAN (dependencias `blocks`/`blocked_by`) seguían abiertas — ni `done` ni `cancelled`. Es un AVISO, no un rechazo: el cambio ya se ha aplicado y la respuesta es la de siempre. Ausente cuando no hay bloqueantes pendientes o cuando el PATCH no cierra la tarea.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Task"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Tarea, proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteTask",
        "x-tool": {
          "name": "delete_task",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Eliminar tarea",
        "description": "Elimina la tarea del proyecto.",
        "responses": {
          "204": {
            "description": "Tarea eliminada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Tarea, proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/tasks/{task_id}/tags": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "description": "UUID de la tarea.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "put": {
        "operationId": "setTaskTags",
        "x-tool": {
          "name": "set_task_tags",
          "domain": "pm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Fijar etiquetas de la tarea",
        "description": "Reemplaza el conjunto COMPLETO de etiquetas de la tarea por `tag_ids`. Requiere ser miembro. Todos los ids deben ser etiquetas de la organización (si no, `validation_error`). Devuelve la tarea con sus `tags` actualizados.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "tag_ids"
                ],
                "properties": {
                  "tag_ids": {
                    "type": "array",
                    "description": "UUIDs de las etiquetas a asociar (conjunto exacto; puede ser vacío).",
                    "items": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Tarea con sus etiquetas actualizadas.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Task"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Tarea, proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Algún `tag_id` no es una etiqueta de la organización (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/tasks/{task_id}/subtasks": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "description": "UUID de la tarea padre.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listSubtasks",
        "x-tool": {
          "name": "list_subtasks",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Listar subtareas",
        "description": "Devuelve las subtareas directas de la tarea y el rollup de progreso (total / done). El usuario debe ser miembro de la organización.",
        "responses": {
          "200": {
            "description": "Subtareas y progreso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SubtaskList"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Tarea, proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/tasks/{task_id}/activity": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "description": "UUID de la tarea.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getTaskActivity",
        "x-tool": {
          "name": "get_task_activity",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Bitácora de actividad de la tarea",
        "description": "Devuelve el feed de actividad (bitácora) de la tarea: entradas de auditoría relacionadas con ella (creación, cambios de estado, comentarios, etiquetas), de más reciente a más antigua. Requiere ser miembro de la organización.\n\nPaginable con `limit`/`offset` (retrocompatible: sin parámetros devuelve como mucho 200 entradas). No emite `X-Total-Count`: el total exacto de la bitácora no se calcula (el feed se filtra sobre la auditoría de la org).",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de entradas de actividad (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/TaskActivityEntry"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Tarea, proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/tasks/{task_id}/status-time": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "description": "UUID de la tarea.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getTaskStatusTime",
        "tags": [
          "tasks"
        ],
        "summary": "Tiempo por estado de la tarea",
        "description": "Devuelve el tiempo acumulado (en segundos) que la tarea ha estado en cada estado, incluidos los estados PERSONALIZADOS de columna. El estado actual cuenta hasta ahora. Requiere ser miembro de la organización.",
        "responses": {
          "200": {
            "description": "Desglose de tiempo-en-estado de la tarea.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StatusTime"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Tarea, proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/tasks/{task_id}/dependencies": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "description": "UUID de la tarea origen.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listTaskDependencies",
        "x-tool": {
          "name": "list_task_dependencies",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Listar dependencias de una tarea",
        "description": "Devuelve todas las dependencias (blocks/blocked_by/relates_to) de la tarea, LAS DOS DIRECCIONES: las que ella declaró (`source_task_id` = `:task_id`) y las que otra tarea declaró contra ella (`target_task_id` = `:task_id`). El sentido de cada fila se lee combinando `source_task_id`/`target_task_id` con `dep_type`: `blocks` significa que la tarea origen bloquea a la destino, `blocked_by` lo contrario, y `relates_to` es simétrica. Es decir, «esta tarea está bloqueada por X» aparece tanto si se escribió desde ella (`blocked_by` hacia X) como desde X (`blocks` hacia ella).",
        "responses": {
          "200": {
            "description": "Lista de dependencias (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/TaskDependency"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Tarea, proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "addTaskDependency",
        "x-tool": {
          "name": "add_task_dependency",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Añadir dependencia",
        "description": "Crea una dependencia entre la tarea origen (`:task_id`) y la tarea destino. Para `dep_type` `blocks` o `blocked_by` se comprueba que no haya ciclos; si los hay devuelve `409 conflict_error`. Para `relates_to` no hay guarda de ciclos. Ambas tareas deben pertenecer al mismo proyecto y organización.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "target_task_id",
                  "dep_type"
                ],
                "properties": {
                  "target_task_id": {
                    "type": "string",
                    "format": "uuid",
                    "description": "UUID de la tarea destino."
                  },
                  "dep_type": {
                    "type": "string",
                    "description": "Tipo de relación de dependencia.",
                    "enum": [
                      "blocks",
                      "blocked_by",
                      "relates_to"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Dependencia creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaskDependency"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Tarea, proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Dependencia que crearía un ciclo (`conflict_error`) o ya existe (`conflict_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido o tarea destino en otro proyecto (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/tasks/{task_id}/dependencies/{dep_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "description": "UUID de la tarea origen.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "dep_id",
          "in": "path",
          "required": true,
          "description": "UUID de la dependencia.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "removeTaskDependency",
        "x-tool": {
          "name": "remove_task_dependency",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Eliminar dependencia",
        "description": "Elimina la dependencia indicada. Vale desde CUALQUIERA de sus dos extremos: `:task_id` puede ser la tarea origen o la destino de la dependencia, igual que el listado la enseña desde los dos lados.",
        "responses": {
          "204": {
            "description": "Dependencia eliminada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Dependencia, tarea, proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/my-work": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getMyWork",
        "x-tool": [
          {
            "name": "list_my_work",
            "domain": "pm",
            "profile": "core",
            "sensitive": false
          },
          {
            "name": "list_tasks",
            "domain": "pm",
            "profile": "all",
            "sensitive": false
          },
          {
            "name": "list_issues",
            "domain": "pm",
            "profile": "core",
            "sensitive": false
          }
        ],
        "tags": [
          "tasks"
        ],
        "summary": "Mis tareas (cross-project)",
        "description": "Vista cross-project: todas las tareas asignadas al usuario autenticado en cualquier proyecto de la organización. Soporta filtros opcionales por `status` y `overdue=true`. Ordenado por `due_date asc nulls last`.",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filtrar por status de tarea (los 4 base o un estado personalizado de tablero).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "overdue",
            "in": "query",
            "required": false,
            "description": "Si `true`, solo tareas con `due_date` anterior a hoy (no-done).",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de tareas asignadas al usuario en la organización (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/MyWorkTask"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/sla-policies": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listSlaPolicies",
        "tags": [
          "sla"
        ],
        "summary": "Listar políticas de SLA",
        "description": "Políticas de SLA de la organización, ordenadas por objetivo y nombre. Las condiciones (`start_categories`, `stop_categories`) se devuelven ya resueltas, aunque no se hayan configurado. Requiere member+.",
        "responses": {
          "200": {
            "description": "Lista de políticas (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/SlaPolicy"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "La organización no tiene la función en su plan (`feature_not_in_plan`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createSlaPolicy",
        "tags": [
          "sla"
        ],
        "summary": "Crear política de SLA",
        "description": "Crea un compromiso de servicio. Requiere **manager+**: un plazo es lo que la empresa promete a un cliente, no una preferencia de pantalla — bajarlo de 8 h a 1 h convierte en incumplida media bandeja.\n\nEl `project_id` del alcance, si se envía, debe pertenecer a esta organización (422 `invalid_project`).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SlaPolicyCreateIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Política creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SlaPolicy"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (manager+) o función fuera del plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido, o `project_id` de otra organización (`invalid_project`), o una categoría de pausa que ya está parada por la regla común.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/sla-policies/{policy_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "policy_id",
          "in": "path",
          "required": true,
          "description": "UUID de la política.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getSlaPolicy",
        "tags": [
          "sla"
        ],
        "summary": "Detalle de política de SLA",
        "responses": {
          "200": {
            "description": "Política.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SlaPolicy"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Función fuera del plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Política u organización inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateSlaPolicy",
        "tags": [
          "sla"
        ],
        "summary": "Actualizar política de SLA",
        "description": "Actualización parcial. Cambiar el compromiso DESCARTA los relojes vivos que lo medían —el plazo o el alcance ya no son los mismos— y se vuelven a crear con la política nueva; los relojes ya CERRADOS no se tocan jamás, porque son mediciones pasadas. `target_kind` no se puede cambiar: para otro objetivo se crea otra política. Requiere manager+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SlaPolicyUpdateIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Política actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SlaPolicy"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (manager+) o función fuera del plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Política u organización inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido o `project_id` de otra organización (`invalid_project`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteSlaPolicy",
        "tags": [
          "sla"
        ],
        "summary": "Eliminar política de SLA",
        "description": "Elimina la política y sus relojes VIVOS. Los relojes ya cerrados SOBREVIVEN, desligados y legibles por la copia de la política con la que se midieron: borrar un compromiso no puede borrar los incumplimientos que ya se registraron con él. Requiere manager+.",
        "responses": {
          "204": {
            "description": "Política eliminada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (manager+) o función fuera del plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Política u organización inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/sla-clocks": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_ids",
          "in": "query",
          "required": false,
          "description": "UUIDs de tarea separados por comas (máx. 200). Es como un LISTADO ya cargado pide de una vez el reloj de las filas que enseña, en lugar de una petición por fila. Los ids desconocidos o de otra organización se ignoran, no dan error: un listado no puede romperse porque una tarea se haya borrado entre la carga y esta llamada.",
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "open",
          "in": "query",
          "required": false,
          "description": "Solo relojes VIVOS (sin `closed_at`).",
          "schema": {
            "type": "boolean",
            "default": false
          }
        },
        {
          "name": "breached",
          "in": "query",
          "required": false,
          "description": "Filtra por incumplido (`true`) o no incumplido (`false`).",
          "schema": {
            "type": "boolean"
          }
        },
        {
          "name": "limit",
          "in": "query",
          "required": false,
          "schema": {
            "type": "integer",
            "minimum": 1,
            "maximum": 200,
            "default": 50
          }
        },
        {
          "name": "offset",
          "in": "query",
          "required": false,
          "schema": {
            "type": "integer",
            "minimum": 0,
            "default": 0
          }
        }
      ],
      "get": {
        "operationId": "listSlaClocks",
        "tags": [
          "sla"
        ],
        "summary": "Relojes de SLA de la organización",
        "description": "Relojes de varias tareas de una vez, ordenados por vencimiento más próximo primero (los que no tienen vencimiento van al final). Requiere member+.\n\nA DIFERENCIA del reloj de UNA tarea, esta lectura **no recalcula nada**: devuelve los valores materializados. Recalcular medio listado en una lectura sería el trabajo pesado que la regla 17 manda a segundo plano, y además convertiría en escritura un `GET` de listado.\n\nLa consecuencia hay que conocerla para leer bien la respuesta: `state`, `due_at` y `breached` son fiables —solo cambian cuando cambia el estado de la tarea, y eso ya recalcula el reloj—, pero `consumed_minutes` y `remaining_minutes` están congelados en `elapsed_at` y envejecen mientras el reloj corre. Para una cuenta atrás al minuto, el reloj de la tarea.",
        "responses": {
          "200": {
            "description": "Relojes (puede ser vacío).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/TaskSlaClock"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "La organización no tiene la función en su plan (`feature_not_in_plan`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`task_ids` con más de 200 elementos o paginación fuera de rango.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/tasks/{task_id}/sla": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "description": "UUID de la tarea.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getTaskSla",
        "tags": [
          "sla"
        ],
        "summary": "Relojes de SLA de una tarea",
        "description": "Devuelve un reloj por objetivo aplicable a la tarea (como mucho uno de primera respuesta y uno de resolución). Lista vacía si ninguna política le encaja.\n\nLos relojes VIVOS se refrescan al leer, porque el tiempo consumido de una tarea abierta crece por sí solo; los CERRADOS se devuelven tal y como quedaron y no cambian aunque después se toque el calendario laboral o la política. Requiere member+.",
        "responses": {
          "200": {
            "description": "Relojes de la tarea (puede ser vacío).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/TaskSlaClock"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Función fuera del plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Tarea u organización inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/request-types": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listRequestTypes",
        "tags": [
          "support"
        ],
        "summary": "Listar tipos de petición",
        "description": "Tipos de petición de la organización, por posición y nombre. Cada uno trae sus campos ya resueltos (nombre, tipo y opciones del campo personalizado), para poder pintar el formulario sin una segunda llamada. Requiere member+.",
        "parameters": [
          {
            "name": "only_active",
            "in": "query",
            "required": false,
            "description": "Solo los que hoy se ofrecen al solicitante.",
            "schema": {
              "type": "boolean",
              "default": false
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de tipos (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/RequestType"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "La organización no tiene la función en su plan (`feature_not_in_plan`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createRequestType",
        "tags": [
          "support"
        ],
        "summary": "Crear tipo de petición",
        "description": "Crea un formulario de entrada. Requiere **manager+**: decide qué se le ofrece al cliente y en qué bandeja aterriza, así que cambiarlo redirige el trabajo del equipo. Sin `project_id` se usa —y la primera vez se crea— la cola de soporte de la organización, que es un proyecto de sistema: no consume cupo del plan ni sale en el listado de proyectos.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RequestTypeCreateIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Tipo de petición creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RequestType"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin la función en el plan o rol insuficiente (manager+).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ya hay un tipo con ese nombre (`request_type_name_taken`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Proyecto (`invalid_project`) o campo personalizado (`invalid_custom_field`) que no son de esta organización, o campo repetido (`duplicate_field`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/request-types/{request_type_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "request_type_id",
          "in": "path",
          "required": true,
          "description": "UUID del tipo de petición.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getRequestType",
        "tags": [
          "support"
        ],
        "summary": "Obtener tipo de petición",
        "description": "Requiere member+.",
        "responses": {
          "200": {
            "description": "Tipo de petición con sus campos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RequestType"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "La organización no tiene la función en su plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Tipo u organización inexistentes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateRequestType",
        "tags": [
          "support"
        ],
        "summary": "Actualizar tipo de petición",
        "description": "Actualización parcial; requiere **manager+**. `fields` omitido deja el formulario como está, `fields: []` lo vacía. Desactivar (`active: false`) deja de ofrecerlo sin tocar las peticiones que ya entraron por él.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RequestTypeUpdateIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Tipo actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RequestType"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin la función en el plan o rol insuficiente (manager+).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Tipo u organización inexistentes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ya hay un tipo con ese nombre (`request_type_name_taken`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Proyecto o campo personalizado que no son de esta organización.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteRequestType",
        "tags": [
          "support"
        ],
        "summary": "Borrar tipo de petición",
        "description": "Requiere **manager+**. Las peticiones que entraron por él NO se borran: pierden la referencia al formulario, nunca su contenido. Para dejar de ofrecerlo conservando la trazabilidad, usa `active: false`.",
        "responses": {
          "204": {
            "description": "Tipo borrado."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin la función en el plan o rol insuficiente (manager+).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Tipo u organización inexistentes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/support-requests": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listSupportRequests",
        "tags": [
          "support"
        ],
        "summary": "Listar peticiones",
        "description": "Peticiones de la organización (tareas con canal de entrada), la más reciente primero. Requiere member+.",
        "parameters": [
          {
            "name": "client_id",
            "in": "query",
            "required": false,
            "description": "Filtra por cliente solicitante.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "request_type_id",
            "in": "query",
            "required": false,
            "description": "Filtra por tipo de petición.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "channel",
            "in": "query",
            "required": false,
            "description": "Filtra por canal de entrada. `api` = lo que entró por la ingesta de una web externa. Añadir un valor a un enum de ENTRADA es aditivo: un cliente que no lo mande se comporta igual que antes.",
            "schema": {
              "type": "string",
              "enum": [
                "portal",
                "email",
                "phone",
                "internal",
                "api"
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de peticiones (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/SupportRequest"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "La organización no tiene la función en su plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createSupportRequest",
        "tags": [
          "support"
        ],
        "summary": "Registrar una petición",
        "description": "Alta de una petición desde dentro (una llamada, un aviso presencial). Requiere member+: quien atiende tiene que poder registrar lo que acaba de atender. Si viene `contact_id`, el CLIENTE se deduce de él y se ignora el `client_id` del cuerpo — aceptarlo dejaría colgar la petición de una persona y facturársela a otro. Los campos obligatorios del tipo tienen que venir contestados y no se admiten respuestas a campos que no pregunta.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SupportRequestCreateIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Petición creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SupportRequest"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "La organización no tiene la función en su plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Tipo desactivado (`request_type_inactive`), solicitante que no es de esta organización (`invalid_contact` / `invalid_client`), contacto de baja (`contact_inactive`), falta un campo obligatorio (`missing_required_field`) o se responde a uno no preguntado (`unexpected_field`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/support-requests/{task_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "description": "UUID de la tarea que representa la petición.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getSupportRequest",
        "tags": [
          "support"
        ],
        "summary": "Obtener una petición",
        "description": "Requiere member+. Devuelve 404 si la tarea no existe, no es de esta organización o no es una petición (no tiene canal de entrada).",
        "responses": {
          "200": {
            "description": "Petición.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SupportRequest"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "La organización no tiene la función en su plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Petición u organización inexistentes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/support/saved-replies": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listSupportSavedReplies",
        "tags": [
          "support"
        ],
        "summary": "Listar respuestas guardadas",
        "description": "Respuestas guardadas de la organización, por título. Requiere member+. Pagina como el resto de listas del módulo: array plano con `limit` y `offset`. No se acota «por naturaleza» —una mesa viva acumula plantillas con el USO, no con la adopción— así que el tope va desde el día uno.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de respuestas guardadas (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/SupportSavedReply"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "La organización no tiene la función en su plan (`feature_not_in_plan`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createSupportSavedReply",
        "tags": [
          "support"
        ],
        "summary": "Crear una respuesta guardada",
        "description": "Requiere **manager+**, el mismo mínimo que definir un tipo de petición: lo que se guarda aquí es lo que el equipo le va a decir al cliente. El cuerpo se guarda LITERAL, con sus marcadores sin sustituir.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SupportSavedReplyCreateIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Respuesta guardada creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SupportSavedReply"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin la función en el plan o rol insuficiente (manager+).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ya hay una respuesta con ese atajo en la organización (`saved_reply_shortcut_taken`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Título vacío, cuerpo vacío o atajo que no encaja en el patrón.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/support/saved-replies/{reply_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "reply_id",
          "in": "path",
          "required": true,
          "description": "UUID de la respuesta guardada.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "updateSupportSavedReply",
        "tags": [
          "support"
        ],
        "summary": "Actualizar una respuesta guardada",
        "description": "Actualización parcial; requiere **manager+**. `shortcut: null` le quita el atajo; omitirlo lo deja como está.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SupportSavedReplyUpdateIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Respuesta guardada actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SupportSavedReply"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin la función en el plan o rol insuficiente (manager+).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Respuesta u organización inexistentes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ya hay otra respuesta con ese atajo (`saved_reply_shortcut_taken`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Título vacío, cuerpo vacío o atajo que no encaja en el patrón.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteSupportSavedReply",
        "tags": [
          "support"
        ],
        "summary": "Borrar una respuesta guardada",
        "description": "Requiere **manager+**. No hay «desactivar»: una plantilla no deja rastro en ninguna conversación, así que borrarla no puede romper nada de lo que ya se envió — el texto que se insertó vive en su comentario, no aquí.",
        "responses": {
          "204": {
            "description": "Respuesta guardada borrada."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin la función en el plan o rol insuficiente (manager+).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Respuesta u organización inexistentes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/support/email-channels": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listSupportEmailChannels",
        "tags": [
          "support"
        ],
        "summary": "Listar buzones de correo entrante",
        "description": "Buzones de la organización, por dirección. Requiere member+ y un plan con la función `service_desk`.",
        "responses": {
          "200": {
            "description": "Lista de buzones (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/SupportEmailChannel"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "La organización no tiene la función en su plan (`feature_not_in_plan`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createSupportEmailChannel",
        "tags": [
          "support"
        ],
        "summary": "Dar de alta un buzón de correo entrante",
        "description": "Registra una dirección y el tipo de petición con el que nacen los correos que llegan a ella sin pertenecer a un hilo. Requiere manager+.\n\nLa dirección es única en TODO el sistema (409 `channel_address_taken`), y no solo dentro de la organización: es lo único que decide de qué organización es un correo entrante, así que no puede ser ambigua entre dos tenants.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SupportEmailChannelCreateIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Buzón creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SupportEmailChannel"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente o función fuera de plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Esa dirección ya está dada de alta (`channel_address_taken`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Dirección inválida o con etiqueta `+` (`invalid_channel_address`), o tipo de petición inexistente en la organización (`invalid_request_type`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/support/email-channels/{channel_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "channel_id",
          "in": "path",
          "required": true,
          "description": "UUID del buzón.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "updateSupportEmailChannel",
        "tags": [
          "support"
        ],
        "summary": "Actualizar un buzón de correo entrante",
        "description": "Requiere manager+. Solo se aplican los campos presentes.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SupportEmailChannelUpdateIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Buzón actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SupportEmailChannel"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente o función fuera de plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Buzón inexistente en esta organización.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Esa dirección ya está dada de alta (`channel_address_taken`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Dirección o tipo de petición inválidos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteSupportEmailChannel",
        "tags": [
          "support"
        ],
        "summary": "Borrar un buzón de correo entrante",
        "description": "Requiere manager+. Las peticiones que entraron por esa dirección NO se tocan. A partir del borrado, el correo dirigido a ella se descarta.",
        "responses": {
          "204": {
            "description": "Buzón borrado."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente o función fuera de plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Buzón inexistente en esta organización.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/support/email/inbound/{provider}": {
      "parameters": [
        {
          "name": "provider",
          "in": "path",
          "required": true,
          "description": "Proveedor de recepción que entrega el evento. Hoy solo `resend`. Va en la ruta —y no deducido de las cabeceras— para que cada proveedor traiga su propio verificador de firma y su propio formato, sin que uno pueda hacerse pasar por otro.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "inboundEmailWebhook",
        "tags": [
          "support"
        ],
        "summary": "Receptor de correo entrante (verificación de firma del proveedor)",
        "description": "Endpoint **PÚBLICO** (sin cookie ni PAT — un proveedor de correo no puede autenticarse como usuario).\n\nVerifica la firma del proveedor sobre el CUERPO CRUDO antes de procesar nada, en comparación de tiempo constante, con ventana antirreplay. Rechaza con **401 `invalid_signature`** si falta, no valida o llega fuera de ventana. Si el secreto de firma no está configurado en el servidor, responde **503 `inbound_email_not_configured`** y NO ingesta: un receptor sin firma sería un formulario anónimo para crear peticiones en cualquier organización.\n\nEs idempotente: una reentrega del mismo mensaje no crea una segunda petición ni un segundo comentario.\n\nEl trabajo real (descargar el cuerpo y los adjuntos del proveedor, identificar el hilo, crear la petición) ocurre FUERA del request, en un job: el proveedor recibe el acuse en milisegundos y reintenta solo si el receptor falla de verdad.",
        "security": [],
        "parameters": [
          {
            "name": "svix-id",
            "in": "header",
            "required": false,
            "description": "Identificador de la entrega (proveedor `resend`). Entra en la firma.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "svix-timestamp",
            "in": "header",
            "required": false,
            "description": "Marca de tiempo de la entrega. Fuera de la ventana antirreplay ⇒ 401.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "svix-signature",
            "in": "header",
            "required": false,
            "description": "Lista de firmas `v1,<base64>` sobre `id.timestamp.cuerpo`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "description": "Evento del proveedor (cuerpo crudo; la firma se verifica sobre estos bytes exactos, así que no puede reserializarse).",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Firma válida y evento aceptado. El cuerpo es SIEMPRE el mismo, independientemente de lo que ocurra después con el mensaje.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InboundEmailAck"
                }
              }
            }
          },
          "401": {
            "description": "Firma ausente, inválida o fuera de ventana (`invalid_signature`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proveedor desconocido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "El evento supera el tamaño admitido (`inbound_email_too_large`). Se rechaza ANTES de verificar la firma: calcular un HMAC sobre un cuerpo arbitrariamente grande es trabajo regalado a quien lo mande.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "El receptor no tiene secreto de firma configurado (`inbound_email_not_configured`). No se ingesta nada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/support/ingest-keys": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listSupportIngestKeys",
        "tags": [
          "support"
        ],
        "summary": "Listar credenciales de ingesta",
        "description": "Credenciales de la organización, la más reciente primero, revocadas incluidas. NUNCA devuelve el token. Requiere **manager+** y un plan con la función `service_desk`.",
        "responses": {
          "200": {
            "description": "Lista de credenciales (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/SupportIngestKey"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente o plan sin la función (`feature_not_in_plan`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createSupportIngestKey",
        "tags": [
          "support"
        ],
        "summary": "Acuñar una credencial de ingesta",
        "description": "Crea la credencial y devuelve el token en claro **una sola vez**. Requiere **manager+** —acuñarla es elegir a qué bandeja escribe una web externa, la misma decisión que crear el tipo de petición— y **step-up** (cookie `step_up`), porque crea un acceso desde internet: sin ella, 403 `step_up_required`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SupportIngestKeyCreateIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Credencial creada. Contiene el token en claro; no vuelve a salir.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SupportIngestKeyCreated"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente, plan sin la función (`feature_not_in_plan`) o falta la reconfirmación (`step_up_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente, usuario no miembro, o el `request_type_id` no es de esta organización.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Datos inválidos, o el tipo de petición está desactivado (`request_type_inactive`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/support/ingest-keys/{key_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "key_id",
          "in": "path",
          "required": true,
          "description": "UUID de la credencial.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "revokeSupportIngestKey",
        "tags": [
          "support"
        ],
        "summary": "Revocar una credencial de ingesta",
        "description": "La credencial deja de valer en la petición SIGUIENTE (no en su próxima caducidad). La fila se conserva para la auditoría. Idempotente: revocar dos veces conserva la marca original. Requiere **manager+** y **step-up** (corta un acceso vivo).",
        "responses": {
          "204": {
            "description": "Revocada."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente, plan sin la función o falta step-up.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Credencial inexistente o de otra organización.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/support/ingest/form": {
      "get": {
        "operationId": "getSupportIngestForm",
        "tags": [
          "support"
        ],
        "summary": "Leer el formulario de la credencial de ingesta",
        "description": "El tipo de petición al que escribe ESTA credencial y sus campos, ya resueltos (nombre, tipo y opciones), para poder construir el formulario de la web externa sin copiar UUIDs a mano.\n\nEs la ÚNICA lectura que la credencial permite y está acotada a su propio tipo: no ve la cola, ni las peticiones, ni los demás tipos de la organización. Autenticada con `Authorization: Bearer pjk_ingest_…`.",
        "security": [],
        "responses": {
          "200": {
            "description": "El formulario.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SupportIngestForm"
                }
              }
            }
          },
          "401": {
            "description": "Credencial ausente, inválida, revocada, caducada, o la organización perdió la función en su plan (`invalid_ingest_credential`). Los cinco casos contestan lo MISMO: distinguirlos convertiría el endpoint en un comprobador de tokens.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "El tipo de petición está desactivado (`request_type_inactive`). Retirar un formulario lo retira también para quien integra, y decirlo es lo que permite entender por qué la web dejó de poder abrir incidencias.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/support/ingest/requests": {
      "post": {
        "operationId": "createIngestedSupportRequest",
        "tags": [
          "support"
        ],
        "summary": "Abrir una petición desde una web externa",
        "description": "Crea la petición en la organización y en la cola de la credencial. Autenticada con `Authorization: Bearer pjk_ingest_…`.\n\n**Lo que decide la CREDENCIAL y no quien llama**: la organización, la cola (el proyecto del tipo de petición, que es `NOT NULL` — una web externa no elige bandeja ni hace nacer proyectos) y la prioridad. Un `organization_id` en el cuerpo no existe en el schema y no se leería.\n\n**Solicitante**: ninguno. Un token de servidor identifica a la WEB, no a la persona que rellenó su formulario, así que la petición nace sin contacto en vez de a nombre de alguien que quizá no la escribió.\n\n**Topes**: uno por minuto en Redis y uno por hora contra la base de datos (`hourly_limit` de la credencial). El segundo existe porque el primero es fail-open: si Redis cae, el contador de la base es lo único que queda.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SupportIngestRequestIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Entrega REPETIDA: la misma `idempotency_key` ya se había usado. Se devuelve la petición original con `created: false` y no se abre ninguna nueva.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SupportIngestAccepted"
                }
              }
            }
          },
          "201": {
            "description": "Petición creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SupportIngestAccepted"
                }
              }
            }
          },
          "401": {
            "description": "Credencial ausente, inválida, revocada, caducada o sin la función en el plan (`invalid_ingest_credential`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido; el tipo de petición está desactivado (`request_type_inactive`); faltan campos obligatorios del formulario (`missing_required_field`); se responde a un campo que no pregunta (`unexpected_field`); la cola desapareció (`request_queue_missing`); o la `idempotency_key` ya se usó y su petición se borró después (`ingest_replay_gone`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Cupo superado (`ingest_quota_exceeded` para el tope horario de la credencial, `too_many_requests` para el techo por IP).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/saved-filters": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listSavedFilters",
        "x-tool": {
          "name": "list_saved_filters",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Listar filtros guardados",
        "description": "Devuelve los filtros guardados del usuario autenticado en la organización.",
        "responses": {
          "200": {
            "description": "Lista de filtros (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/SavedFilter"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createSavedFilter",
        "x-tool": {
          "name": "create_saved_filter",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Crear filtro guardado",
        "description": "Guarda un nuevo filtro para el usuario autenticado en la organización. `query` es un objeto libre que los clientes usan para reconstruir el estado del filtro. `entity` dice a qué listado pertenece y es OBLIGATORIO: el contrato no lo declaraba (PJKT-2323) y quien seguía el contrato al pie de la letra recibía un 422 `validation_error` con `entity: Field required`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "entity",
                  "query"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Nombre descriptivo del filtro."
                  },
                  "entity": {
                    "type": "string",
                    "description": "Superficie del filtro (`tasks`, `invoices`, `issues`…). No es un enum cerrado; el servidor lo guarda tal cual (sin espacios alrededor) y no lo infiere, porque un filtro guardado desde el listado de facturas no es un filtro de tareas.",
                    "examples": [
                      "tasks"
                    ]
                  },
                  "query": {
                    "type": "object",
                    "description": "Estado del filtro (objeto libre; los clientes definen su shape).",
                    "additionalProperties": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Filtro guardado creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SavedFilter"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/saved-filters/{filter_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "filter_id",
          "in": "path",
          "required": true,
          "description": "UUID del filtro.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "updateSavedFilter",
        "x-tool": {
          "name": "update_saved_filter",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Actualizar filtro guardado",
        "description": "Actualización parcial de `name` y/o `query`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "query": {
                    "type": "object",
                    "additionalProperties": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Filtro actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SavedFilter"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Filtro u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteSavedFilter",
        "x-tool": {
          "name": "delete_saved_filter",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Eliminar filtro guardado",
        "description": "Elimina el filtro guardado del usuario.",
        "responses": {
          "204": {
            "description": "Filtro eliminado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Filtro u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/desktop-state": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getDesktopState",
        "tags": [
          "desktop-state"
        ],
        "summary": "Leer el escritorio guardado",
        "description": "Devuelve el escritorio que esta persona dejó guardado en esta organización, con la versión y el dispositivo que lo escribió. Si nunca guardó nada, contesta 200 con `layout: null` y `version: 0` — no 404: «todavía no hay escritorio» es la primera respuesta de todo el mundo y no es un fallo.",
        "responses": {
          "200": {
            "description": "El escritorio guardado (o el hueco, con `layout: null`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DesktopState"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "put": {
        "operationId": "saveDesktopState",
        "tags": [
          "desktop-state"
        ],
        "summary": "Guardar el escritorio (condicional)",
        "description": "Guarda el escritorio SOLO si `base_version` coincide con la versión que hay en el servidor. Si no coincide, otro dispositivo escribió entremedias y la petición se rechaza con 409 `desktop_state_stale` sin tocar nada guardado.\n\nEse 409 no es un error que reintentar en bucle: es la señal de que hay dos escritorios vivos. El cliente vuelve a leer y deja elegir a la persona — «Retomar» adopta el del otro sitio, «Seguir aquí» reescribe con la versión recién leída—. Reintentar a ciegas con la versión nueva es exactamente el «gana el último» que esto viene a quitar.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DesktopStateSave"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Guardado; devuelve el estado ya con la versión nueva.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DesktopState"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Otro dispositivo guardó después de la versión que trae `base_version` (`desktop_state_stale`). No se ha escrito nada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/reminders": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listReminders",
        "tags": [
          "reminders"
        ],
        "summary": "Listar mis recordatorios",
        "description": "Los recordatorios del usuario autenticado en esta organización, con los PENDIENTES primero y, dentro de cada grupo, el aviso más próximo antes. Pagina con `limit`/`offset` desde el día uno: la lista crece con el USO diario, no con la adopción, así que no cabe en las «acotadas por naturaleza».\n\nTodos los filtros se COMBINAN (AND), y todos se aplican SIEMPRE sobre los tuyos: la privacidad no es uno más de los filtros, va por debajo de todos ellos en el repositorio.\n\nÉste es también el endpoint del que come el CALENDARIO: con `remind_after` y `remind_before` pide la ventana que está pintando —y solo los del que mira, que es lo que se decidió el 31/08/2026— en vez de traerse la lista entera y filtrarla en el navegador.",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Qué recordatorios devolver. `pending` (defecto) es lo que casi siempre se quiere ver; `done` es el histórico; `all` los dos. El defecto es `pending` y no `all` porque una lista que arranca con seis meses de cosas hechas encima esconde justo lo que hay que hacer hoy.",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "done",
                "all"
              ],
              "default": "pending"
            }
          },
          {
            "name": "archived",
            "in": "query",
            "required": false,
            "description": "`false` (defecto) devuelve solo los que están a la vista; `true`, solo el archivo. No hay «los dos»: archivar sirve precisamente para quitar cosas de delante, y una opción que las devuelve mezcladas deshace el único efecto que tiene. Es ORTOGONAL a `status`: se puede archivar algo pendiente, y `archived=true&status=pending` es justo cómo se recupera.",
            "schema": {
              "type": "boolean",
              "default": false
            }
          },
          {
            "name": "list_id",
            "in": "query",
            "required": false,
            "description": "Solo los de esta lista (tiene que ser tuya; si no, 404). Omitirlo devuelve los de TODAS las listas y también los de la bandeja de entrada. Para pedir solo la bandeja, ver `inbox`.",
            "schema": {
              "type": [
                "string",
                "null"
              ],
              "format": "uuid"
            }
          },
          {
            "name": "inbox",
            "in": "query",
            "required": false,
            "description": "`true` devuelve solo los de la BANDEJA DE ENTRADA (`list_id = null`). Existe como parámetro aparte porque «sin lista» no se puede pedir con `list_id`: un `list_id=` vacío en una query string es indistinguible de no mandarlo, así que la bandeja se quedaría sin forma de pedirse. Mandar `inbox=true` y `list_id` a la vez es 422 — se contradicen.",
            "schema": {
              "type": "boolean",
              "default": false
            }
          },
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Busca este texto, sin distinguir mayúsculas, en el texto, las notas y la ubicación de LOS TUYOS. Los comodines de SQL (`%`, `_`) se escapan: se busca lo que se escribió, no un patrón. No pasa por la búsqueda global del producto (`/search`) a propósito — esa es de la organización y la ven todos los miembros, y meter ahí un recordatorio privado lo enseñaría a gente que no puede abrirlo.",
            "schema": {
              "type": [
                "string",
                "null"
              ],
              "minLength": 1,
              "maxLength": 200
            }
          },
          {
            "name": "remind_after",
            "in": "query",
            "required": false,
            "description": "Solo los que avisan en este instante o después (UTC, inclusive). Con `remind_before`, es cómo el CALENDARIO pide «mis recordatorios de este mes» sin traerse la lista entera.",
            "schema": {
              "type": [
                "string",
                "null"
              ],
              "format": "date-time"
            }
          },
          {
            "name": "remind_before",
            "in": "query",
            "required": false,
            "description": "Solo los que avisan en este instante o antes (UTC, inclusive).",
            "schema": {
              "type": [
                "string",
                "null"
              ],
              "format": "date-time"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de recordatorios (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Reminder"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente, usuario no miembro, o el `list_id` pedido no existe o es de otra persona (`not_found`). 404 y no 403 en el último caso, por lo mismo que en el resto del módulo: un 403 confirmaría que esa lista existe y que es de alguien de esta organización.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros de query inválidos (`validation_error`): `status` o `q` fuera de rango, o `inbox=true` junto con `list_id`, que se contradicen.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createReminder",
        "tags": [
          "reminders"
        ],
        "summary": "Crear un recordatorio",
        "description": "Crea un recordatorio a nombre del usuario autenticado. No hay campo de propietario en el cuerpo a propósito: el dueño sale de la sesión, nunca de un id que mande el cliente.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ReminderCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Recordatorio creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Reminder"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`): texto vacío, falta `remind_at`, la tarea/cliente enganchados no son de esta organización, o el `list_id` no es una lista TUYA de esta organización.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/reminders/{reminder_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "reminder_id",
          "in": "path",
          "required": true,
          "description": "UUID del recordatorio.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "updateReminder",
        "tags": [
          "reminders"
        ],
        "summary": "Actualizar un recordatorio",
        "description": "Actualización parcial. Marcar `done: true` sella `done_at` en el servidor y reabrirlo (`done: false`) lo vuelve a poner a `null`: si la marca de tiempo la mandara el cliente, dos navegadores con relojes distintos escribirían historias distintas del mismo recordatorio. `archived` funciona igual con `archived_at`.\n\nEs también por donde el CALENDARIO marca hecho un recordatorio: allí se ve y se tacha, pero se EDITA en su app — un `PATCH {\"done\": true}` es todo lo que la retícula necesita mandar.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ReminderUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Recordatorio actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Reminder"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Recordatorio u organización inexistente, usuario no miembro, o el recordatorio es de OTRA persona (`not_found`). Se contesta 404 y no 403 a propósito: un 403 confirmaría que ese id existe y de quién es.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`): texto en blanco, o la tarea/cliente/lista a los que se quiere enganchar no son de esta organización (y, en el caso de la lista, tuyos).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteReminder",
        "tags": [
          "reminders"
        ],
        "summary": "Eliminar un recordatorio",
        "description": "Borrado DEFINITIVO. Sigue existiendo aunque desde el 31/08/2026 se pueda archivar, porque son dos gestos distintos: archivar (`PATCH {\"archived\": true}`) es «quítamelo de delante» y se deshace; borrar es «esto nunca debió estar aquí» y no. Quitar el borrado dejaría a quien se equivocó escribiendo sin más salida que archivar la equivocación para siempre.",
        "responses": {
          "204": {
            "description": "Recordatorio eliminado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Recordatorio u organización inexistente, usuario no miembro, o el recordatorio es de otra persona (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/reminder-lists": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listReminderLists",
        "tags": [
          "reminders"
        ],
        "summary": "Listar mis listas de recordatorios",
        "description": "Las listas del usuario autenticado en esta organización, en su orden (`position` ascendente, y el `id` como desempate para que dos listas con la misma posición no bailen entre dos peticiones).\n\nNo pagina, y ésta sí es de las «acotadas por naturaleza»: las listas las crea una persona a mano, de una en una, y nadie mantiene doscientas. El número de recordatorios crece con el USO diario; el de listas, con la adopción — que es exactamente el criterio de entrada a `ACOTADAS_POR_NATURALEZA` en `app/core/tests/test_listados_con_tope.py`, donde queda registrada con su motivo. Sin ese registro el guardián de listados pone el árbol en rojo, así que la decisión no se puede tomar en silencio (comprobado: la primera versión de este endpoint lo puso rojo).",
        "parameters": [
          {
            "name": "archived",
            "in": "query",
            "required": false,
            "description": "`false` (defecto) devuelve solo las activas; `true`, solo las archivadas. Mismo criterio que en los recordatorios.",
            "schema": {
              "type": "boolean",
              "default": false
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de listas (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ReminderList"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createReminderList",
        "tags": [
          "reminders"
        ],
        "summary": "Crear una lista de recordatorios",
        "description": "Crea una lista a nombre del usuario autenticado. El dueño sale de la sesión y la `position` la pone el servidor al final de las suyas; el cuerpo no admite ninguna de las dos.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ReminderListCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Lista creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ReminderList"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`): nombre en blanco o más largo de 60, color o icono fuera de tope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/reminder-lists/{list_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "list_id",
          "in": "path",
          "required": true,
          "description": "UUID de la lista.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "updateReminderList",
        "tags": [
          "reminders"
        ],
        "summary": "Actualizar una lista de recordatorios",
        "description": "Actualización parcial: renombrar, recolorear, cambiar el icono, moverla de sitio o archivarla. `archived: true` sella `archived_at` en el servidor y `false` lo devuelve a `null`, igual que `done_at` en un recordatorio.\n\nArchivar una lista NO toca los recordatorios de dentro: siguen colgando de ella con su `list_id` intacto y vuelven a verse al desarchivarla. Vaciar la lista al archivarla sería perder la agrupación que la persona construyó, y es justo lo que quiere recuperar cuando la desarchiva.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ReminderListUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Lista actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ReminderList"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Lista u organización inexistente, usuario no miembro, o la lista es de OTRA persona (`not_found`). 404 y no 403 a propósito: un 403 confirmaría que ese id existe y de quién es.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteReminderList",
        "tags": [
          "reminders"
        ],
        "summary": "Eliminar una lista de recordatorios",
        "description": "Borra la lista. Sus recordatorios NO se borran: caen a la bandeja de entrada (`list_id` a `null`). Borrar en cascada convertiría «ya no quiero esta agrupación» en «bórrame veinte recordatorios», que es trabajo de la persona desapareciendo de refilón — y sin avisar, porque el gesto que pulsó hablaba de la lista, no de lo que hay dentro.",
        "responses": {
          "204": {
            "description": "Lista eliminada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Lista u organización inexistente, usuario no miembro, o la lista es de otra persona (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/notes": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listNotes",
        "tags": [
          "notes"
        ],
        "summary": "Listar mis notas",
        "description": "Las notas del usuario autenticado en esta organización, SIN el cuerpo: título y vista previa derivados, más sus marcas y sus fechas. El cuerpo se pide al abrir una (`GET /notes/{note_id}`); traerlo aquí serían hasta 200 cuerpos de hasta 50.000 caracteres para pintar dos renglones de cada uno.\n\nEl orden es el que hace que la de arriba sea siempre la que estabas escribiendo: primero las FIJADAS, y dentro de cada grupo la última tocada (`updated_at` descendente), con el `id` de desempate para que dos notas guardadas en el mismo instante no bailen entre dos páginas.\n\nPagina con `limit`/`offset` desde el día uno: las notas crecen con el USO diario —ésa es la app—, no con la adopción, así que no cabe en las «acotadas por naturaleza».",
        "parameters": [
          {
            "name": "archived",
            "in": "query",
            "required": false,
            "description": "`false` (defecto) devuelve solo las que están a la vista; `true`, solo el archivo. No hay «las dos»: archivar sirve para quitar cosas de delante, y una opción que las devuelve mezcladas deshace el único efecto que tiene. Mismo criterio que en Recordatorios.",
            "schema": {
              "type": "boolean",
              "default": false
            }
          },
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Busca este texto, sin distinguir mayúsculas, en el CUERPO de las tuyas — y por tanto también en su título y su vista previa, que salen de ahí. Los comodines de SQL (`%`, `_`) se escapan: se busca lo que se escribió, no un patrón.",
            "schema": {
              "type": [
                "string",
                "null"
              ],
              "minLength": 1,
              "maxLength": 200
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de notas sin cuerpo (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Note"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros de query inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createNote",
        "tags": [
          "notes"
        ],
        "summary": "Crear una nota",
        "description": "Crea una nota a nombre del usuario autenticado. El cuerpo de la petición puede ir VACÍO (`{}`): eso es lo que manda el `+` de la barra, que abre el editor sobre una nota que ya existe en el servidor. Es la diferencia entre «tengo dónde escribir» y «tengo un borrador en el navegador hasta el primer guardado», que es donde se pierden las notas.\n\nNo hay campo de propietario: el dueño sale de la sesión.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/NoteCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Nota creada, con su cuerpo (vacío si no se mandó ninguno).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NoteDetail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`): más de 50.000 caracteres, o un campo que este alta no admite (título, carpeta, dueño… no existen).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/notes/trash": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listNoteTrash",
        "tags": [
          "notes"
        ],
        "summary": "Listar mis notas en papelera",
        "description": "Notas borradas y todavía restaurables, de la más recientemente borrada a la más antigua — que es el orden en el que alguien busca lo que acaba de tirar. Solo las TUYAS.\n\nLleva el mismo recorte que el listado normal: se devuelve el título y la vista previa derivados, no el cuerpo entero. Una papelera de treinta días no es sitio para arrastrar megas de markdown; para leer la nota se restaura, que es de lo que va esta pantalla.\n\nLo que pasó de los 30 días de retención ya no está: se lo lleva el barrido nocturno.",
        "responses": {
          "200": {
            "description": "Notas en papelera (lista, sin paginar).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Note"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/notes/{note_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "note_id",
          "in": "path",
          "required": true,
          "description": "UUID de la nota.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getNote",
        "tags": [
          "notes"
        ],
        "summary": "Leer una nota entera",
        "description": "La nota con su cuerpo. Existe porque el LISTADO no lo trae: es la petición que se hace al abrirla, y la única que mueve texto largo.",
        "responses": {
          "200": {
            "description": "La nota con su cuerpo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NoteDetail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Nota u organización inexistente, usuario no miembro, o la nota es de OTRA persona (`not_found`). 404 y no 403 a propósito, en los tres casos: un 403 confirmaría que ese UUID existe y que es de alguien de esta organización, y desde una cuenta legítima se podrían ir probando.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateNote",
        "tags": [
          "notes"
        ],
        "summary": "Actualizar una nota (y autoguardarla)",
        "description": "Actualización parcial, y también el AUTOGUARDADO: una nota rápida no tiene botón de guardar, así que la pantalla dispara este PATCH cada pocos segundos mientras alguien escribe. Por eso es idempotente y barato, y las dos cosas las garantiza el SERVIDOR y no el debounce de quien llama:\n\n· Un PATCH que no cambia nada no escribe nada y NO mueve `updated_at`. Como el listado ordena por `updated_at`, moverlo en cada latido reordenaría la barra lateral bajo el cursor de quien está escribiendo.\n\n· `pinned` y `archived` son órdenes; sus sellos (`pinned_at`, `archived_at`) los pone el servidor, nunca el cliente. Dos navegadores con relojes distintos escribirían dos historias de la misma nota, y la diferencia solo se vería meses después mirando las fechas.\n\nNo hay bloqueo optimista ni 409: la nota es de una persona y gana la última escritura (ver `NoteUpdate`).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/NoteUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Nota actualizada, con su cuerpo y su título ya rederivado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NoteDetail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Nota u organización inexistente, usuario no miembro, o la nota es de otra persona (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`): más de 50.000 caracteres, o un campo que no existe (`title` y `preview` se derivan, no se escriben).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteNote",
        "tags": [
          "notes"
        ],
        "summary": "Eliminar una nota",
        "description": "Manda la nota a la PAPELERA, y sigue conviviendo con archivar porque son dos gestos distintos: archivar (`PATCH {\"archived\": true}`) es «quítamela de delante» y la nota sigue siendo parte de la app; borrar es «esto no debería existir» y la saca de todo — del listado, del archivo y del `q`.\n\nDesde la ola 1(B) de la HIG (01/09/2026) ese borrado ya NO es definitivo: la nota se queda 30 días en `GET .../notes/trash`, de donde se restaura entera. Lo definitivo es el `purge`, y por eso es lo único que se pregunta.\n\nEn esta app el borrado tiene además un uso que en Recordatorios no tenía: el `+` crea la nota ANTES de que nadie escriba, así que quien abre una y se arrepiente deja una nota en blanco. Ésa se borra, no se archiva — archivar la basura solo la mueve de cajón.",
        "responses": {
          "204": {
            "description": "Nota enviada a la papelera; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Nota u organización inexistente, usuario no miembro, o la nota es de otra persona (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/notes/{note_id}/restore": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "note_id",
          "in": "path",
          "required": true,
          "description": "UUID de la nota en papelera.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "restoreNote",
        "tags": [
          "notes"
        ],
        "summary": "Restaurar una nota desde la papelera",
        "description": "Devuelve la nota al listado con su cuerpo intacto, y devuelve la nota ENTERA (`NoteDetail`, con `body`) y no la versión recortada: quien restaura casi siempre quiere abrirla a continuación, y pedirla otra vez sería una llamada que ya sabemos que va a hacer.\n\nRestaurar respeta lo que estaba: si la nota estaba archivada o fijada cuando se borró, vuelve archivada o fijada. La papelera no es un estado más de la nota, es una capa por encima.\n\nUn `note_id` que existe pero NO está en la papelera responde `404`, igual que uno inexistente o que uno de otra persona.",
        "responses": {
          "200": {
            "description": "Nota restaurada, con su cuerpo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NoteDetail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Nota u organización inexistente, nota que no está en papelera, usuario no miembro, o la nota es de otra persona (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/notes/{note_id}/purge": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "note_id",
          "in": "path",
          "required": true,
          "description": "UUID de la nota a eliminar permanentemente.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "purgeNote",
        "tags": [
          "notes"
        ],
        "summary": "Eliminar una nota permanentemente (purge)",
        "description": "Hard delete, sin vuelta, y solo sobre notas que YA están en la papelera. No hay rol que pedir —la nota es de quien pregunta o no existe—, así que lo único que separa esto de un accidente es la confirmación de la pantalla. Es la única operación de este módulo que la merece.",
        "responses": {
          "204": {
            "description": "Nota eliminada permanentemente; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Nota u organización inexistente, nota que no está en papelera, usuario no miembro, o la nota es de otra persona (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/board": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getBoard",
        "x-tool": {
          "name": "get_board",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "projects"
        ],
        "summary": "Tablero unificado del proyecto",
        "description": "Devuelve el tablero completo en una sola llamada: las columnas (persistidas o por defecto) con sus tareas agrupadas por `status_key`, el contador por columna y la señal `over_wip` (columna con `wip_limit` superado). Con `swimlane` distinto de `none`, cada columna incluye además sus carriles (agrupación por asignado/prioridad/tipo). Requiere ser miembro.",
        "parameters": [
          {
            "name": "swimlane",
            "in": "query",
            "required": false,
            "description": "Criterio de agrupación en carriles dentro de cada columna.",
            "schema": {
              "type": "string",
              "enum": [
                "none",
                "assignee",
                "priority",
                "type"
              ],
              "default": "none"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Tablero del proyecto.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Board"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/board-columns": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listBoardColumns",
        "x-tool": {
          "name": "list_board_columns",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "projects"
        ],
        "summary": "Listar columnas del tablero",
        "description": "Devuelve las columnas del tablero en orden de posición. Si el proyecto no tiene columnas configuradas, devuelve las seis columnas por defecto con UUIDs centinela: `todo`, `in_progress`, `in_review`, `done`, `cancelled` y `blocked` (esta última al final, porque no es un paso del flujo sino un paréntesis).",
        "responses": {
          "200": {
            "description": "Columnas del tablero (nunca vacío; mínimo los defaults).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/BoardColumn"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createBoardColumn",
        "x-tool": {
          "name": "create_board_column",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "projects"
        ],
        "summary": "Crear columna del tablero",
        "description": "Añade una columna al tablero del proyecto. `position` se usa para ordenar; las columnas existentes no se reordenan automáticamente (usa `reorderBoardColumns`).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "status_key"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Nombre visible de la columna."
                  },
                  "status_key": {
                    "type": "string",
                    "pattern": "^[a-z0-9_]+$",
                    "maxLength": 20,
                    "description": "Clave de status. Admite los 4 valores base (todo/in_progress/done/cancelled) y estados PERSONALIZADOS como slug propio (p.ej. `en_revision`)."
                  },
                  "category": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "enum": [
                      "todo",
                      "in_progress",
                      "in_review",
                      "blocked",
                      "done",
                      "cancelled",
                      null
                    ],
                    "description": "Semántica de la columna (workflow + time-tracking). Si se omite, se deriva del `status_key` —incluidos los slugs conocidos de revisión (`in_review`/`en_revision`/`review`) y de bloqueo— y `in_progress` para cualquier otro. Solo `todo` e `in_progress` acumulan tiempo imputable."
                  },
                  "color": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Color hex opcional del chip (p.ej. `#8b5cf6`)."
                  },
                  "position": {
                    "type": "integer",
                    "description": "Posición (0-based) de la columna. Si se omite o es 0, la columna se añade al final del tablero."
                  },
                  "wip_limit": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "Límite de tareas en progreso simultáneo; `null` = sin límite."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Columna creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BoardColumn"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/board-columns/reorder": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "put": {
        "operationId": "reorderBoardColumns",
        "x-tool": {
          "name": "reorder_board_columns",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "projects"
        ],
        "summary": "Reordenar columnas del tablero",
        "description": "Reordena las columnas del tablero según el array de UUIDs enviado. Todos los IDs deben ser columnas del proyecto; la posición se reasigna en el orden del array.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "column_ids"
                ],
                "properties": {
                  "column_ids": {
                    "type": "array",
                    "description": "UUIDs de las columnas en el nuevo orden.",
                    "items": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Columnas reordenadas (devuelve la lista completa en el nuevo orden).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/BoardColumn"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Algún UUID no es columna del proyecto (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/board-columns/{column_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "column_id",
          "in": "path",
          "required": true,
          "description": "UUID de la columna.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "updateBoardColumn",
        "x-tool": {
          "name": "update_board_column",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "projects"
        ],
        "summary": "Actualizar columna del tablero",
        "description": "Actualización parcial de una columna (nombre, categoría, color, wip_limit, posición).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "category": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "enum": [
                      "todo",
                      "in_progress",
                      "in_review",
                      "blocked",
                      "done",
                      "cancelled",
                      null
                    ],
                    "description": "Semántica de la columna (workflow + time-tracking). Solo `todo` e `in_progress` acumulan tiempo imputable; `in_review` y `blocked` no, porque la tarea está esperando a otro."
                  },
                  "color": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Color hex del chip; `null`/vacío lo elimina."
                  },
                  "position": {
                    "type": "integer"
                  },
                  "wip_limit": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "`null` elimina el límite."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Columna actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BoardColumn"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Columna, proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteBoardColumn",
        "x-tool": {
          "name": "delete_board_column",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "projects"
        ],
        "summary": "Eliminar columna del tablero",
        "description": "Elimina la columna del tablero. No mueve las tareas asignadas.",
        "responses": {
          "204": {
            "description": "Columna eliminada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Columna, proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/sprints": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listSprints",
        "x-tool": {
          "name": "list_sprints",
          "domain": "pm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "sprints"
        ],
        "summary": "Listar sprints",
        "description": "Sprints del proyecto; requiere ser miembro de la organización.",
        "responses": {
          "200": {
            "description": "Lista de sprints (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Sprint"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createSprint",
        "x-tool": {
          "name": "create_sprint",
          "domain": "pm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "sprints"
        ],
        "summary": "Crear sprint",
        "description": "Crea un sprint en el proyecto (status inicial `planning`). Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 160
                  },
                  "goal": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Objetivo opcional; puede omitirse o enviarse `null`."
                  },
                  "status": {
                    "type": "string",
                    "description": "Opcional; por defecto `planning`.",
                    "enum": [
                      "planning",
                      "active",
                      "completed"
                    ]
                  },
                  "start_date": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date",
                    "description": "Fecha de inicio opcional (ISO `YYYY-MM-DD`)."
                  },
                  "end_date": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date",
                    "description": "Fecha de fin opcional (ISO `YYYY-MM-DD`)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Sprint creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Sprint"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); crear requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/sprints/{sprint_id}/stats": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "sprint_id",
          "in": "path",
          "required": true,
          "description": "UUID del sprint.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "sprintStats",
        "x-tool": {
          "name": "sprint_stats",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "sprints"
        ],
        "summary": "Estadísticas de un sprint",
        "description": "Agregados de tareas y puntos del sprint (por estado). Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Agregados del sprint.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SprintStats"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Sprint, proyecto u organización inexistente, o no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/sprints/{sprint_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "sprint_id",
          "in": "path",
          "required": true,
          "description": "UUID del sprint.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getSprint",
        "x-tool": {
          "name": "get_sprint",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "sprints"
        ],
        "summary": "Detalle de sprint",
        "description": "Devuelve el sprint si pertenece a la organización y el usuario es miembro.",
        "responses": {
          "200": {
            "description": "Sprint.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Sprint"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Sprint u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateSprint",
        "x-tool": {
          "name": "update_sprint",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "sprints"
        ],
        "summary": "Actualizar sprint",
        "description": "Actualización parcial; todos los campos opcionales. `goal`/`start_date`/ `end_date` admiten `null` (lo limpian); `name`/`status` no. Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 160
                  },
                  "goal": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra el objetivo."
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "planning",
                      "active",
                      "completed"
                    ]
                  },
                  "start_date": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date",
                    "description": "`null` la limpia. Omitir no la toca."
                  },
                  "end_date": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date",
                    "description": "`null` la limpia. Omitir no la toca."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Sprint actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Sprint"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); editar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Sprint u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteSprint",
        "x-tool": {
          "name": "delete_sprint",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "sprints"
        ],
        "summary": "Eliminar sprint",
        "description": "Elimina el sprint; las tareas que estaban en él quedan con `sprint_id` `null` (no se borran). Requiere admin+.",
        "responses": {
          "204": {
            "description": "Sprint eliminado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); borrar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Sprint u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/sprints/{sprint_id}/tasks": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "sprint_id",
          "in": "path",
          "required": true,
          "description": "UUID del sprint.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "assignSprintTasks",
        "x-tool": {
          "name": "assign_sprint_tasks",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "sprints"
        ],
        "summary": "Asignar tareas al sprint",
        "description": "Fija `sprint_id` en un lote de tareas. Todas deben ser del MISMO proyecto que el sprint (si no, `validation_error`). Duplicados se colapsan. Para sacar una tarea del sprint, usa `PATCH` de la tarea con `sprint_id: null`. Requiere admin+. Devuelve los stats del sprint tras la asignación.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "task_ids"
                ],
                "properties": {
                  "task_ids": {
                    "type": "array",
                    "description": "UUIDs de tareas del proyecto a asignar al sprint.",
                    "items": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Tareas asignadas; stats del sprint.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SprintStats"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); asignar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Sprint u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Alguna tarea no es del proyecto del sprint (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/sprints/{sprint_id}/burndown": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "sprint_id",
          "in": "path",
          "required": true,
          "description": "UUID del sprint.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "sprintBurndown",
        "x-tool": {
          "name": "get_sprint_burndown",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "sprints"
        ],
        "summary": "Burndown del sprint",
        "description": "Serie ideal-vs-real de puntos restantes, calculada desde los datos actuales de las tareas (sin snapshots). Si al sprint le faltan fechas, `days` va vacío. Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Burndown calculado del sprint.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SprintBurndown"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Sprint u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/sprints/{sprint_id}/complete": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "sprint_id",
          "in": "path",
          "required": true,
          "description": "UUID del sprint.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "completeSprint",
        "x-tool": {
          "name": "complete_sprint",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "sprints"
        ],
        "summary": "Cerrar sprint (con rollover)",
        "description": "Pone el sprint en `completed` y hace ROLLOVER de sus tareas incompletas (`todo`/`in_progress`): van al sprint `active` del MISMO proyecto más próximo (si hay varios, el de `start_date` más temprano) o, si no hay ninguno, al backlog (`sprint_id: null`, igual que sacar una tarea de un sprint por `PATCH`). Las tareas `done`/`cancelled` NO se mueven: quedan en el sprint cerrado, congelando sus stats. Requiere admin+. Repetir el cierre sobre un sprint ya `completed` devuelve `409 sprint_already_completed` (el rollover no es idempotente).",
        "responses": {
          "200": {
            "description": "Sprint cerrado, con sus stats finales y el destino del rollover.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SprintComplete"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); cerrar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Sprint u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "El sprint ya estaba `completed` (`sprint_already_completed`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/velocity": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "limit",
          "in": "query",
          "required": false,
          "description": "Solo los últimos N sprints completados (media móvil de velocidad); si se omite, devuelve el histórico completo. Entre 1 y 100.",
          "schema": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100
          }
        }
      ],
      "get": {
        "operationId": "projectVelocity",
        "x-tool": {
          "name": "project_velocity",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "sprints"
        ],
        "summary": "Velocidad histórica del proyecto",
        "description": "Story points y nº de tareas `done` de cada sprint `completed` del proyecto (los sprints en `planning`/`active` no cuentan todavía), en orden cronológico ascendente, más el promedio simple. Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Histórico de velocidad del proyecto.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SprintVelocity"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`limit` fuera de rango (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/tasks/{task_id}/comments": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "description": "UUID de la tarea.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listTaskComments",
        "x-tool": {
          "name": "list_task_comments",
          "domain": "pm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "task-comments"
        ],
        "summary": "Listar comentarios",
        "description": "Comentarios de la tarea (más recientes primero). Lista plana; el cliente reconstruye los hilos por `parent_id`. Requiere ser miembro.\n\nPaginable con `limit`/`offset` (retrocompatible: sin parámetros devuelve como mucho 200 comentarios, igual que antes). La cabecera `X-Total-Count` trae el total de comentarios de la tarea, sin paginar. Ojo: al paginar, un hilo puede quedar partido entre páginas — el cliente debe tolerar `parent_id` que apunte a un comentario fuera de la página.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de comentarios (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de comentarios de la tarea (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/TaskComment"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Tarea, proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros de paginación inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createTaskComment",
        "x-tool": {
          "name": "add_comment",
          "domain": "pm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "task-comments"
        ],
        "summary": "Crear comentario",
        "description": "Crea un comentario en la tarea (autor = usuario actual). Requiere ser miembro. `parent_id` opcional para responder a otro comentario de la MISMA tarea.\n\n`visibility` decide quién lo lee y por DEFECTO es `internal`: si la tarea es una petición de soporte, un comentario `public` lo verá el cliente en su portal. El defecto es interno a propósito — que una respuesta se quede sin publicar es una molestia; que una nota interna se publique es un incidente que no se puede deshacer.\n\nY si la petición ENTRÓ por correo, un comentario `public` además SE ENVÍA por correo al solicitante, en el mismo hilo del que vino. Es decir: marcar `public` aquí no es «publicar en una pantalla», es mandar un correo que no se puede retirar. La respuesta dice por dónde salió en `channel`. Un comentario `internal` no sale NUNCA de la organización, por ningún canal.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "body"
                ],
                "properties": {
                  "body": {
                    "type": "string",
                    "description": "Cuerpo del comentario (texto libre, no vacío)."
                  },
                  "parent_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Comentario padre (de la misma tarea) al que se responde; opcional."
                  },
                  "visibility": {
                    "type": "string",
                    "enum": [
                      "internal",
                      "public"
                    ],
                    "default": "internal",
                    "description": "`internal` (defecto) = solo el equipo. `public` = respuesta visible para el cliente en el portal de su petición."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Comentario creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaskComment"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Tarea, proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido o `parent_id` ajeno a la tarea (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/tasks/{task_id}/comments/{comment_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "description": "UUID de la tarea.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "comment_id",
          "in": "path",
          "required": true,
          "description": "UUID del comentario.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "updateTaskComment",
        "x-tool": {
          "name": "update_task_comment",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "task-comments"
        ],
        "summary": "Editar comentario",
        "description": "Edita el cuerpo del comentario y marca `is_edited=true`. Solo el autor del comentario o un admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "body"
                ],
                "properties": {
                  "body": {
                    "type": "string",
                    "description": "Nuevo cuerpo del comentario (texto libre, no vacío)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Comentario actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaskComment"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es el autor ni admin+ (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Comentario, tarea, proyecto u organización inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteTaskComment",
        "x-tool": {
          "name": "delete_task_comment",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "task-comments"
        ],
        "summary": "Eliminar comentario",
        "description": "Elimina el comentario (y sus respuestas en cascada). Solo el autor del comentario o un admin+.",
        "responses": {
          "204": {
            "description": "Comentario eliminado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es el autor ni admin+ (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Comentario, tarea, proyecto u organización inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/tags": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listTags",
        "x-tool": {
          "name": "list_tags",
          "domain": "pm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "tags"
        ],
        "summary": "Listar etiquetas",
        "description": "Etiquetas de la organización; requiere ser miembro. Ordenadas por nombre.",
        "responses": {
          "200": {
            "description": "Lista de etiquetas (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Tag"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createTag",
        "x-tool": {
          "name": "create_tag",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "tags"
        ],
        "summary": "Crear etiqueta",
        "description": "Crea una etiqueta en la organización. Requiere admin+. El nombre es único (case-insensitive) por organización; `color` por defecto `slate`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 50
                  },
                  "color": {
                    "type": "string",
                    "default": "slate",
                    "description": "Color hex (`#rgb`/`#rrggbb`) o nombre de token."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Etiqueta creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Tag"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); crear requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ya existe una etiqueta con ese nombre en la organización (`conflict`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/tags/{tag_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "tag_id",
          "in": "path",
          "required": true,
          "description": "UUID de la etiqueta.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "updateTag",
        "x-tool": {
          "name": "update_tag",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "tags"
        ],
        "summary": "Actualizar etiqueta",
        "description": "Actualización parcial; todos los campos son opcionales. Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 50
                  },
                  "color": {
                    "type": "string",
                    "description": "Color hex (`#rgb`/`#rrggbb`) o nombre de token."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Etiqueta actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Tag"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); actualizar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Etiqueta u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ya existe otra etiqueta con ese nombre en la organización (`conflict`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteTag",
        "x-tool": {
          "name": "delete_tag",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "tags"
        ],
        "summary": "Eliminar etiqueta",
        "description": "Elimina la etiqueta de la organización (se desasocia de todas las tareas). Requiere admin+.",
        "responses": {
          "204": {
            "description": "Etiqueta eliminada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); eliminar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Etiqueta u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/invoices": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listInvoices",
        "x-tool": {
          "name": "list_invoices",
          "domain": "finance",
          "profile": "core",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Listar facturas",
        "description": "Facturas de la organización; requiere ser miembro. Filtrable por `status`, `project_id` (el proyecto IMPUTADO), rango de `issue_date` (`date_from`/`date_to`) y texto (`search`, contra `invoice_number` o `client_name`, case-insensitive). Ordenable por `sort` + `dir`. Sin parámetros mantiene el orden por defecto (alta ascendente).",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filtra por estado exacto.",
            "schema": {
              "type": "string",
              "enum": [
                "draft",
                "sent",
                "paid",
                "overdue",
                "cancelled"
              ]
            }
          },
          {
            "name": "project_id",
            "in": "query",
            "required": false,
            "description": "Filtra por el proyecto IMPUTADO — el mismo `project_id` que se guarda al crear o editar la factura, no el proyecto del cliente.\n\nVale un proyecto PROPIO de la organización o uno AJENO COMPARTIDO con ella (enlace aceptado): exactamente los que se pueden imputar al escribir, porque es la MISMA puerta (`require_project`). Cualquier otro —inexistente o de otra organización— es `404 not_found`, NUNCA una lista vacía: un permiso denegado no debe leerse como «no hay nada».\n\nLa VISIBILIDAD del proyecto (`visibility: restricted`, miembros explícitos, departamentos) NO entra en este gate, y es deliberado: ver finanzas exige ya `admin` (`VIEW_FINANCE_MIN_ROLE`) y `admin` es exactamente el rol que se salta lo restringido (`BYPASS_RESTRICTED_MIN_ROLE`), así que comprobarla aquí no cambiaría hoy ni una respuesta. Si ese mínimo bajara alguna vez a `member`, esta frase deja de ser cierta: un `member` distinguiría por el 404-contra-200 qué proyectos restringidos existen, y habría que pasar también por `ensure_project_visible` — en la lectura Y en la escritura, que comparten puerta a propósito.\n\nNo hay valor para «sin imputar»: pedir las facturas SIN proyecto necesitaría un parámetro propio y explícito, que hoy no existe.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "date_from",
            "in": "query",
            "required": false,
            "description": "`issue_date` >= (fecha ISO 8601).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "date_to",
            "in": "query",
            "required": false,
            "description": "`issue_date` <= (fecha ISO 8601).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "description": "Busca en `invoice_number` o `client_name` (case-insensitive).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Campo de ordenación.",
            "schema": {
              "type": "string",
              "default": "created_at",
              "enum": [
                "created_at",
                "number",
                "client",
                "status",
                "issue_date",
                "due_date",
                "total"
              ]
            }
          },
          {
            "name": "dir",
            "in": "query",
            "required": false,
            "description": "Sentido de la ordenación.",
            "schema": {
              "type": "string",
              "default": "asc",
              "enum": [
                "asc",
                "desc"
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de facturas (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de facturas que cumplen los filtros activos (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Invoice"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente, usuario no miembro, o `project_id` que esta organización no puede imputar (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros de filtro/orden inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createInvoice",
        "x-tool": {
          "name": "create_invoice",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Crear factura",
        "description": "Crea una factura (status inicial `draft`). El servidor calcula `subtotal`, `tax_amount` y `total` a partir de las líneas y `tax_rate`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "client_name",
                  "issue_date",
                  "due_date",
                  "items"
                ],
                "properties": {
                  "client_name": {
                    "type": "string"
                  },
                  "client_email": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "email",
                    "description": "Opcional; puede omitirse o enviarse `null`."
                  },
                  "issue_date": {
                    "type": "string",
                    "format": "date",
                    "description": "Fecha de emisión."
                  },
                  "due_date": {
                    "type": "string",
                    "format": "date",
                    "description": "Fecha de vencimiento."
                  },
                  "items": {
                    "type": "array",
                    "description": "Líneas de la factura (al menos una).",
                    "items": {
                      "type": "object",
                      "required": [
                        "description",
                        "quantity",
                        "unit_price"
                      ],
                      "properties": {
                        "description": {
                          "type": "string"
                        },
                        "quantity": {
                          "type": "number"
                        },
                        "unit_price": {
                          "type": "number"
                        }
                      }
                    }
                  },
                  "tax_rate": {
                    "type": "number",
                    "description": "Tipo impositivo (p. ej. 21). Opcional.",
                    "default": 0
                  },
                  "notes": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Opcional; puede omitirse o enviarse `null`."
                  },
                  "client_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Ficha de cliente (CRM) a vincular. Debe pertenecer a la organización (404 si no). Al vincular, los datos fiscales no enviados (`client_email`, `client_tax_id`, `client_address`) se autorrellenan desde la ficha."
                  },
                  "project_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Opcional; proyecto a imputar (rentabilidad)."
                  },
                  "client_tax_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "NIF/CIF del cliente (snapshot en la factura)."
                  },
                  "client_address": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Dirección fiscal del cliente (snapshot en la factura)."
                  },
                  "payment_method": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Método de pago (texto libre)."
                  },
                  "cost_center_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Centro de coste asociado."
                  },
                  "is_intra_eu": {
                    "type": "boolean",
                    "description": "Operación intracomunitaria.",
                    "default": false
                  },
                  "currency": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "minLength": 3,
                    "maxLength": 3,
                    "description": "Divisa de la factura en ISO 4217. Omitida (o `null`) = la divisa base de la organización (`Organization.default_currency`). Hasta ahora el servidor escribía `EUR` a pelo y este campo no existía, así que una factura en dólares se guardaba y se devolvía como euros. NO hay conversión: el importe se guarda tal cual en la divisa indicada.",
                    "examples": [
                      "USD"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Factura creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Invoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente para gestionar finanzas (`forbidden`) o el plan de la organización no incluye el módulo (`feature_not_in_plan`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/invoices/from-time": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "createInvoiceFromTime",
        "tags": [
          "finance"
        ],
        "summary": "Crear factura desde tiempo imputado",
        "description": "Genera una factura (status inicial `draft`) agregando el tiempo facturable imputado en el proyecto/cliente indicados dentro del rango de fechas. Cada línea corresponde a un usuario o a una tarea según `group_by`. El servidor calcula `subtotal`, `tax_amount` y `total` a partir de las líneas y `tax_rate`, y marca los registros de tiempo consumidos con `invoice_id`. Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/InvoiceFromTime"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Factura creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Invoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización, proyecto o cliente inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/invoices/bulk": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "bulkInvoiceAction",
        "tags": [
          "finance"
        ],
        "summary": "Acción en lote sobre facturas",
        "description": "Aplica una acción a varias facturas. Las acciones de estado (approve/reject/mark_paid/void) siguen la máquina de estados por elemento. La acción `set_client` es una asignación directa: vincula el cliente indicado en `client_id` a cada factura con el mismo comportamiento que el PATCH individual (reemplaza snapshot si el cliente cambia, rellena vacíos si es el mismo; `null` desvincula). Devuelve un resultado honesto: `processed` (cambiaron), `skipped` (ya en estado destino) y `errors`. Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "ids",
                  "action"
                ],
                "properties": {
                  "ids": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "type": "string",
                      "format": "uuid"
                    }
                  },
                  "action": {
                    "type": "string",
                    "enum": [
                      "approve",
                      "reject",
                      "mark_paid",
                      "void",
                      "set_client"
                    ]
                  },
                  "client_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Requerido cuando `action` es `set_client`. UUID del cliente (org-scoped; 404 si no existe). `null` desvincula el cliente de todas las facturas del lote."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Resultado por-elemento de la acción.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BulkActionResult"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/invoices/renumber": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "renumberInvoices",
        "tags": [
          "finance"
        ],
        "summary": "Renumerar la serie de facturas de forma correlativa",
        "description": "Renumera las facturas cuyo número pertenece a la serie `{prefix}-…` (default `INV`) de forma correlativa por fecha de emisión (desempate por fecha de creación): `{prefix}-00001…N` sin huecos. Otras series de la organización quedan intactas. Con `dry_run: true` (default) devuelve el plan completo sin escribir nada. Requiere admin+. Queda auditado con el mapping de cambios.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "prefix": {
                    "type": "string",
                    "default": "INV",
                    "description": "Serie a renumerar (1-10 caracteres alfanuméricos)."
                  },
                  "dry_run": {
                    "type": "boolean",
                    "default": true,
                    "description": "`true` = solo devuelve el plan, no escribe."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Plan o resultado de la renumeración.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InvoiceRenumberResult"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/invoices/export": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "exportInvoicesCsv",
        "tags": [
          "finance"
        ],
        "summary": "Exportar facturas como CSV",
        "description": "Exporta **todas** las facturas que cumplen los filtros activos (sin paginación) en formato CSV UTF-8. Requiere ser miembro. Columnas: número, cliente, estado, fecha_emision, fecha_venc, subtotal, iva, total, moneda.\n\nAcepta los MISMOS filtros que `GET /invoices`, `project_id` incluido, y a propósito: una exportación que ignore un filtro de la pantalla no sale vacía ni da error — sale con más filas de las pedidas, y quien la abre no tiene forma de notarlo.",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filtra por estado exacto.",
            "schema": {
              "type": "string",
              "enum": [
                "draft",
                "sent",
                "paid",
                "overdue",
                "cancelled"
              ]
            }
          },
          {
            "name": "project_id",
            "in": "query",
            "required": false,
            "description": "Filtra por el proyecto IMPUTADO, con la misma puerta y la misma respuesta que en `GET /invoices`: propio o ajeno-compartido-aceptado, y cualquier otro `404 not_found`.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "date_from",
            "in": "query",
            "required": false,
            "description": "`issue_date` >= (fecha ISO 8601).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "date_to",
            "in": "query",
            "required": false,
            "description": "`issue_date` <= (fecha ISO 8601).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "description": "Busca en `invoice_number` o `client_name` (case-insensitive).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Archivo CSV con cabecera (UTF-8).",
            "headers": {
              "Content-Disposition": {
                "description": "attachment; filename=\"facturas.csv\"",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente, usuario no miembro, o `project_id` que esta organización no puede imputar (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros de filtro inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/invoices/by-number/{invoice_number}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "invoice_number",
          "in": "path",
          "required": true,
          "description": "Número legible de la factura (p. ej. INV-2026-0042).",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getInvoiceByNumber",
        "tags": [
          "finance"
        ],
        "summary": "Detalle de factura por número legible",
        "description": "Devuelve la factura (incluidas sus líneas) identificada por su número legible dentro de la organización. Equivale a `GET /invoices/{invoice_id}` pero acepta el número visible en lugar del UUID. `404` si el número no pertenece a la organización.",
        "responses": {
          "200": {
            "description": "Factura (con `items`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Invoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Número de factura no encontrado en la organización (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/invoices/{invoice_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "invoice_id",
          "in": "path",
          "required": true,
          "description": "UUID de la factura.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getInvoice",
        "x-tool": {
          "name": "get_invoice",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Detalle de factura",
        "description": "Devuelve la factura (incluidas sus líneas) si pertenece a la organización y el usuario es miembro.",
        "responses": {
          "200": {
            "description": "Factura (con `items`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Invoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Factura u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateInvoice",
        "x-tool": {
          "name": "update_invoice",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Actualizar factura",
        "description": "Actualización parcial; todos los campos son opcionales. `issue_date`, `items` y `tax_rate` solo son editables mientras la factura está en BORRADOR (422 en cualquier otro estado); al cambiar `items`/`tax_rate` el servidor recalcula subtotal, cuota y total y REEMPLAZA las líneas.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "draft",
                      "sent",
                      "paid",
                      "overdue",
                      "cancelled"
                    ]
                  },
                  "client_name": {
                    "type": "string"
                  },
                  "client_email": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "email",
                    "description": "`null` borra el email del cliente."
                  },
                  "issue_date": {
                    "type": "string",
                    "format": "date",
                    "description": "Fecha de emisión. SOLO editable en borrador."
                  },
                  "due_date": {
                    "type": "string",
                    "format": "date"
                  },
                  "items": {
                    "type": "array",
                    "minItems": 1,
                    "description": "Reemplaza TODAS las líneas (solo en borrador); el servidor recalcula los totales.",
                    "items": {
                      "type": "object",
                      "required": [
                        "description",
                        "quantity",
                        "unit_price"
                      ],
                      "properties": {
                        "description": {
                          "type": "string"
                        },
                        "quantity": {
                          "type": "number"
                        },
                        "unit_price": {
                          "type": "number"
                        }
                      }
                    }
                  },
                  "tax_rate": {
                    "type": "number",
                    "description": "Tipo impositivo (p. ej. 21). SOLO editable en borrador."
                  },
                  "notes": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra las notas."
                  },
                  "client_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Vincula una ficha de cliente (CRM) de la organización (404 si no existe). Si el cliente enviado DIFIERE del vinculado actual, el snapshot completo (`client_name`, `client_email`, `client_tax_id`, `client_address`) se REEMPLAZA con los datos de la ficha nueva; lo enviado explícitamente en este PATCH siempre gana. Si el cliente es el mismo, solo se rellenan los campos vacíos (comportamiento anterior). `null` desvincula sin tocar el snapshot."
                  },
                  "project_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Proyecto a imputar (rentabilidad). `null` lo desasocia."
                  },
                  "client_tax_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "NIF/CIF del cliente (snapshot). `null` lo borra."
                  },
                  "client_address": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Dirección fiscal del cliente (snapshot). `null` la borra."
                  },
                  "payment_method": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Método de pago (texto libre). `null` lo borra."
                  },
                  "cost_center_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Centro de coste. `null` lo desasocia."
                  },
                  "is_intra_eu": {
                    "type": "boolean",
                    "description": "Operación intracomunitaria."
                  },
                  "currency": {
                    "type": "string",
                    "minLength": 3,
                    "maxLength": 3,
                    "description": "Divisa de la factura en ISO 4217. SOLO editable en borrador, igual que `issue_date`/`items`/`tax_rate` (422 en cualquier otro estado): una factura emitida es un documento fiscal cerrado y su divisa forma parte de lo emitido. NO hay conversión: cambiarla REETIQUETA los importes, no los recalcula. No admite `null`.",
                    "examples": [
                      "USD"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Factura actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Invoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente para gestionar finanzas (`forbidden`) o el plan de la organización no incluye el módulo (`feature_not_in_plan`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Factura u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteInvoice",
        "x-tool": {
          "name": "delete_invoice",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Eliminar factura",
        "description": "Elimina la factura de la organización. Una factura APROBADA (emitida, cobrada o vencida) solo puede eliminarla un `owner`/`admin`; en borrador o anulada basta el permiso de gestión financiera.",
        "responses": {
          "204": {
            "description": "Factura eliminada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`): gestión financiera requiere admin+, y eliminar una factura aprobada exige owner/admin.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Factura u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/invoices/{invoice_id}/pdf": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "invoice_id",
          "in": "path",
          "required": true,
          "description": "UUID de la factura.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "downloadInvoicePdf",
        "tags": [
          "finance"
        ],
        "summary": "Descargar factura en PDF",
        "description": "Genera y devuelve la factura como PDF (`application/pdf`) con cabecera `Content-Disposition: attachment; filename=\"{invoice_number}.pdf\"`. El logo se incrusta como base64 — nunca se realizan peticiones externas. Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "PDF de la factura.",
            "headers": {
              "Content-Disposition": {
                "description": "attachment; filename=\"INV-2026-0042.pdf\"",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Factura u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/invoices/{invoice_id}/share": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "invoice_id",
          "in": "path",
          "required": true,
          "description": "UUID de la factura.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listInvoiceShares",
        "tags": [
          "finance"
        ],
        "summary": "Listar tokens de compartición activos",
        "description": "Devuelve los tokens activos (no revocados, no expirados) de la factura. El campo `token` siempre es null en el listado — el valor raw solo se expone una vez en el POST de creación. Requiere admin+.",
        "responses": {
          "200": {
            "description": "Lista de tokens activos.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/InvoiceShare"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Factura u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createInvoiceShare",
        "tags": [
          "finance"
        ],
        "summary": "Crear token de compartición pública",
        "description": "Genera un token urlsafe aleatorio, almacena únicamente su hash SHA-256, y devuelve el token raw + URL pública UNA SOLA VEZ en `token` y `public_url`. Las llamadas posteriores nunca vuelven a exponer el raw. Requiere admin+.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "expires_in_days": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "minimum": 1,
                    "description": "Días hasta la expiración. `null` = sin expiración.",
                    "examples": [
                      30
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Token creado (incluye `token` raw y `public_url`; solo en esta respuesta).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InvoiceShare"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Factura u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/invoices/{invoice_id}/reminders": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "invoice_id",
          "in": "path",
          "required": true,
          "description": "UUID de la factura.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listInvoiceReminders",
        "tags": [
          "finance"
        ],
        "summary": "Listar recordatorios de cobro enviados",
        "description": "Recordatorios de cobro ya enviados al cliente de esta factura, más reciente primero. Cada fila dice qué hito se cubrió (1, 7 o 15 días), a qué dirección salió y cuándo: es la respuesta a «¿por qué ha recibido mi cliente este correo?». Requiere admin+.",
        "responses": {
          "200": {
            "description": "Lista de recordatorios enviados (vacía si no salió ninguno).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/InvoiceReminder"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Factura u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/invoices/{invoice_id}/share/{share_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "invoice_id",
          "in": "path",
          "required": true,
          "description": "UUID de la factura.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "share_id",
          "in": "path",
          "required": true,
          "description": "UUID del registro del token de compartición.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "revokeInvoiceShare",
        "tags": [
          "finance"
        ],
        "summary": "Revocar token de compartición",
        "description": "Revoca el token marcando `revoked_at` = ahora. El enlace público queda inactivo inmediatamente (devuelve 404). Requiere admin+.",
        "responses": {
          "204": {
            "description": "Token revocado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Token, factura u organización inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/invoices/{token}": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token raw urlsafe obtenido al crear el enlace de compartición.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getPublicInvoice",
        "tags": [
          "finance"
        ],
        "summary": "Ver factura pública (sin login)",
        "description": "Devuelve una vista de solo lectura de la factura accesible mediante el token. No requiere autenticación. `404` si el token es inválido, revocado o expirado. No expone ids internos, notas ni datos de auditoría. Rate limit: 20 req / 60 s por IP.",
        "responses": {
          "200": {
            "description": "Vista pública de la factura.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicInvoice"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido, revocado o expirado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/invoices/{token}/pdf": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token raw urlsafe.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "downloadPublicInvoicePdf",
        "tags": [
          "finance"
        ],
        "summary": "Descargar PDF de factura pública (sin login)",
        "description": "Genera y devuelve el PDF de la factura accesible por token sin autenticación. Mismas restricciones de validez que `GET /public/invoices/{token}`. Rate limit: 20 req / 60 s por IP.",
        "responses": {
          "200": {
            "description": "PDF de la factura.",
            "headers": {
              "Content-Disposition": {
                "description": "attachment; filename=\"INV-2026-0042.pdf\"",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido, revocado o expirado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/quotes": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listQuotes",
        "tags": [
          "finance"
        ],
        "summary": "Listar presupuestos",
        "description": "Presupuestos de la organización; requiere ser miembro. Filtrable por `status`. Paginable con `limit`/`offset`.",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filtra por estado exacto.",
            "schema": {
              "type": "string",
              "enum": [
                "draft",
                "sent",
                "approved",
                "rejected",
                "converted",
                "expired"
              ]
            }
          },
          {
            "name": "client_id",
            "in": "query",
            "required": false,
            "description": "Filtra por cliente CRM vinculado (UUID de la ficha de cliente).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "description": "Busca en `quote_number` o `client_name` (case-insensitive).",
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Columna de ordenación. Fuera del enum es 422: la clave se traduce a columna por tabla, no se interpola.",
            "schema": {
              "type": "string",
              "default": "created_at",
              "enum": [
                "created_at",
                "number",
                "client",
                "status",
                "issue_date",
                "expires_at",
                "total"
              ]
            }
          },
          {
            "name": "dir",
            "in": "query",
            "required": false,
            "description": "Sentido de la ordenación.",
            "schema": {
              "type": "string",
              "default": "asc",
              "enum": [
                "asc",
                "desc"
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de presupuestos (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de presupuestos que cumplen los filtros activos (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Quote"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros de filtro inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createQuote",
        "x-tool": {
          "name": "create_quote",
          "domain": "crm",
          "superficies": [
            "kern"
          ]
        },
        "tags": [
          "finance"
        ],
        "summary": "Crear presupuesto",
        "description": "Crea un presupuesto (status inicial `draft`). El servidor calcula `subtotal`, `tax_amount` y `total` a partir de las líneas y `tax_rate`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/QuoteCreateIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Presupuesto creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Quote"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/quotes/{quote_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "quote_id",
          "in": "path",
          "required": true,
          "description": "UUID del presupuesto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getQuote",
        "tags": [
          "finance"
        ],
        "summary": "Detalle de presupuesto",
        "description": "Devuelve el presupuesto (incluidas sus líneas) si pertenece a la organización y el usuario es miembro.",
        "responses": {
          "200": {
            "description": "Presupuesto (con `items`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Quote"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Presupuesto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateQuote",
        "tags": [
          "finance"
        ],
        "summary": "Actualizar presupuesto",
        "description": "Actualización parcial; todos los campos son opcionales. `issue_date`, `expires_at`, `items`, `tax_rate` y `deposit_percent` solo son editables mientras el presupuesto está en BORRADOR (422 en cualquier otro estado); al cambiar `items`/`tax_rate` el servidor recalcula subtotal, cuota y total y REEMPLAZA las líneas.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/QuoteUpdateIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Presupuesto actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Quote"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Presupuesto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido, o edición de un campo solo-borrador en un estado no borrador (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteQuote",
        "tags": [
          "finance"
        ],
        "summary": "Eliminar presupuesto",
        "description": "Elimina el presupuesto de la organización. Requiere el permiso de gestión financiera (admin+).",
        "responses": {
          "204": {
            "description": "Presupuesto eliminado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Presupuesto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "El presupuesto no está en borrador (`quote_not_draft`): enviado, aceptado, rechazado o convertido ya es historial y no se borra.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/quotes/{quote_id}/send": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "quote_id",
          "in": "path",
          "required": true,
          "description": "UUID del presupuesto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "sendQuote",
        "tags": [
          "finance"
        ],
        "summary": "Enviar (emitir) presupuesto",
        "description": "Transición `draft`→`sent`. Idempotente si ya está `sent`. `422` si el estado actual no permite la transición. Requiere admin+.",
        "responses": {
          "200": {
            "description": "Presupuesto tras la acción.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Quote"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Presupuesto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Transición no válida desde el estado actual (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/quotes/{quote_id}/share": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "quote_id",
          "in": "path",
          "required": true,
          "description": "UUID del presupuesto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "createQuoteShare",
        "tags": [
          "finance"
        ],
        "summary": "Crear token de compartición pública",
        "description": "Genera un token urlsafe aleatorio, almacena únicamente su hash SHA-256, y devuelve el token raw + URL pública UNA SOLA VEZ en `token` y `public_url`. Las llamadas posteriores nunca vuelven a exponer el raw. Requiere admin+.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/QuoteShareCreateIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Token creado (incluye `token` raw y `public_url`; solo en esta respuesta).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QuoteShareToken"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Presupuesto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/quotes/{quote_id}/convert": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "quote_id",
          "in": "path",
          "required": true,
          "description": "UUID del presupuesto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "convertQuoteToInvoice",
        "tags": [
          "finance"
        ],
        "summary": "Convertir presupuesto en factura",
        "description": "Genera una factura (`draft`) a partir del presupuesto y lo marca como `converted`, fijando `converted_invoice_id`. Si se indica `deposit_percent` (o el presupuesto lo trae), la factura cubre solo ese anticipo. `422` si el estado actual no permite la conversión. Requiere admin+.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/QuoteConvertIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Presupuesto convertido (con `converted_invoice_id` fijado).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Quote"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Presupuesto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Conversión no válida desde el estado actual (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/quotes/{quote_id}/convert-to-invoice": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "quote_id",
          "in": "path",
          "required": true,
          "description": "UUID del presupuesto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "createInvoiceFromQuote",
        "x-tool": {
          "name": "convert_quote_to_invoice",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Convertir presupuesto en factura (devuelve la factura)",
        "description": "Genera una factura (`draft`) a partir del presupuesto aprobado —copiando sus líneas y totales— y devuelve LA FACTURA, cerrando el flujo comercial→cobro. Marca el presupuesto como `converted` y fija `converted_invoice_id`. Si se indica `deposit_percent` (o el presupuesto lo trae), la factura cubre solo ese anticipo (una única línea 'Depósito X%'). **Idempotente**: si el presupuesto ya se había convertido y la factura sigue viva, devuelve ESA factura sin crear otra. `422` si el presupuesto no está `approved`. Requiere admin+.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/QuoteConvertIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Factura creada a partir del presupuesto, o la ya vinculada si el presupuesto se había convertido antes (idempotente).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Invoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Presupuesto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "El presupuesto no está aprobado (`quote_not_approved`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/quotes/{quote_id}/project": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "quote_id",
          "in": "path",
          "required": true,
          "description": "UUID del presupuesto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getQuoteProject",
        "tags": [
          "projects"
        ],
        "summary": "Proyecto creado a partir del presupuesto",
        "description": "Devuelve el proyecto que se abrió desde este presupuesto, o `404` si aún no se ha creado ninguno (o si el que había está en la papelera). Requiere admin+, igual que el resto del módulo de presupuestos.",
        "responses": {
          "200": {
            "description": "Proyecto vinculado al presupuesto.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Project"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Presupuesto inexistente, usuario no miembro, o el presupuesto todavía no tiene proyecto (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createProjectFromQuote",
        "tags": [
          "projects"
        ],
        "summary": "Crear el proyecto del presupuesto aceptado",
        "description": "Abre el proyecto con el que se ejecutará un presupuesto ACEPTADO (`approved`) o ya convertido en factura (`converted`). Arrastra lo que identifica el trabajo —nombre, cliente CRM vinculado y una descripción con el número de presupuesto, su importe y sus líneas— y NO arrastra los importes como campos del proyecto: el dinero sigue viviendo en el presupuesto y en la factura, que son su fuente de verdad.\n\n**Idempotente**: si el presupuesto ya tiene proyecto, devuelve ESE proyecto sin crear otro (pulsar dos veces no abre dos proyectos). `409` si el proyecto que se creó está en la papelera —restáuralo en vez de duplicarlo—. `422` si el presupuesto no está aceptado. Requiere admin+.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/QuoteProjectCreateIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Proyecto creado a partir del presupuesto, o el ya vinculado si existía (idempotente).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Project"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`) o límite de proyectos del plan alcanzado (`project_limit_reached`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Presupuesto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "El proyecto creado desde este presupuesto está en la papelera (`quote_project_in_trash`), o colisión de clave (`project_key_taken`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "El presupuesto no está aceptado (`quote_not_approved`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/quotes/{quote_id}/document": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "quote_id",
          "in": "path",
          "required": true,
          "description": "UUID del presupuesto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getQuoteDocument",
        "tags": [
          "documents"
        ],
        "summary": "Documento archivado del presupuesto",
        "description": "Devuelve el documento (`kind: file`) con el PDF del presupuesto aceptado, o `404` si todavía no se ha archivado. Requiere admin+.",
        "responses": {
          "200": {
            "description": "Documento con el PDF del presupuesto.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Document"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Presupuesto inexistente, usuario no miembro, o presupuesto sin archivar (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "archiveQuoteDocument",
        "tags": [
          "documents"
        ],
        "summary": "Archivar el presupuesto aceptado en Documentos",
        "description": "Guarda el PDF del presupuesto ACEPTADO (`approved`) o ya convertido (`converted`) como documento de tipo fichero de la organización, dentro de la carpeta «Presupuestos aceptados» (restringida: solo owner/admin la ven, el mismo umbral con el que se ven los presupuestos). El PDF es EL MISMO que sirve `GET …/quotes/{quote_id}/pdf`.\n\nNormalmente no hace falta llamarlo: el archivado corre solo tras la aceptación. Este endpoint lo fuerza al momento. **Idempotente**: si el presupuesto ya está archivado devuelve ESE documento sin volver a subir el PDF. `422` si el presupuesto no está aceptado. Requiere admin+.",
        "responses": {
          "201": {
            "description": "Documento creado con el PDF del presupuesto, o el ya archivado si existía (idempotente).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Document"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`) o cupo de almacenamiento del plan agotado (`storage_limit_exceeded`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Presupuesto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "La carpeta «Presupuestos aceptados» existe pero es visible para toda la organización (`archive_folder_not_restricted`). Archivar ahí publicaría los precios de todos los clientes, así que se rechaza hasta que la carpeta se restrinja o se renombre.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "El presupuesto no está aceptado (`quote_not_approved`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/quotes/{token}": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token raw urlsafe obtenido al crear el enlace de compartición.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getPublicQuote",
        "tags": [
          "finance"
        ],
        "summary": "Ver presupuesto público (sin login)",
        "description": "Devuelve una vista de solo lectura del presupuesto accesible mediante el token. No requiere autenticación. `404` si el token es inválido, revocado o expirado. No expone ids internos ni datos de auditoría.",
        "security": [],
        "responses": {
          "200": {
            "description": "Vista pública del presupuesto.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicQuote"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido, revocado o expirado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones desde esta IP (`too_many_requests`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/quotes/{token}/approve": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token raw urlsafe obtenido al crear el enlace de compartición.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "approvePublicQuote",
        "tags": [
          "finance"
        ],
        "summary": "Aceptar presupuesto público (sin login)",
        "description": "Registra la aceptación del presupuesto por el cliente (firmante, IP y huella de auditoría) y transiciona el presupuesto a `approved`. No requiere autenticación. `signer_email` es OBLIGATORIO (cambio incompatible de 2026-08): es la dirección del acuse de recibo. `404` si el token es inválido, revocado o expirado. `409` si el presupuesto ya no admite aceptación (ya aceptado, rechazado o convertido). `422` si el presupuesto ha caducado (`quote_expired`) o el cuerpo es inválido.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/QuoteApproveIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Presupuesto aceptado (vista pública actualizada).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicQuote"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido, revocado o expirado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "El presupuesto ya fue decidido: `quote_already_approved` si ya se aceptó o convirtió, `quote_already_rejected` si el cliente lo rechazó.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones desde esta IP (`too_many_requests`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/quotes/{token}/reject": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token raw urlsafe obtenido al crear el enlace de compartición.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "rejectPublicQuote",
        "tags": [
          "finance"
        ],
        "summary": "Rechazar presupuesto público (sin login)",
        "description": "Hermano de la aceptación: registra el rechazo del presupuesto por el cliente (firmante, email obligatorio, IP, momento y motivo opcional) y transiciona el presupuesto a `rejected`. No requiere autenticación. Un presupuesto CADUCADO no se puede rechazar igual que no se puede aceptar (`422 quote_expired`): fuera de plazo la oferta ya no está sobre la mesa. `404` si el token es inválido, revocado o expirado. `409` si el presupuesto ya fue decidido (aceptado, rechazado o convertido). El motivo que escribe el cliente NO es la nota interna del equipo, que nunca se expone aquí.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/QuoteRejectIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Presupuesto rechazado (vista pública actualizada).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicQuote"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido, revocado o expirado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "El presupuesto ya fue decidido: `quote_already_approved` si el cliente lo aceptó (o ya se convirtió en factura), `quote_already_rejected` si ya lo había rechazado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido o presupuesto caducado (`quote_expired`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones desde esta IP (`too_many_requests`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/signatures/{token}/code": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token de compartición del documento (el del enlace público).",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "requestSignatureCode",
        "tags": [
          "quotes"
        ],
        "summary": "Pedir el código de firma por correo",
        "description": "Envía un código de SEIS dígitos al correo que consta en el documento y devuelve esa dirección enmascarada. El código caduca en 10 minutos, sirve una sola vez y admite 5 intentos. Pedir otro invalida el anterior: sin eso, el tope de intentos se sortearía pidiendo códigos en bucle. `404` si el token es inválido, revocado o caducado. `422` si el documento no tiene correo de contacto (`signature_email_unavailable`) o ya no admite decisión. Limitado por IP y por documento.",
        "security": [],
        "responses": {
          "200": {
            "description": "Código emitido y encolado para envío.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SignatureCodeSent"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido, revocado o caducado (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "El documento ya fue firmado (`document_already_signed`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Sin correo de contacto (`signature_email_unavailable`) o documento no firmable en su estado actual.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones (`too_many_requests`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/signatures/{token}/sign": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token de compartición del documento (el del enlace público).",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "signPublicDocument",
        "tags": [
          "quotes"
        ],
        "summary": "Firmar el documento con el código recibido",
        "description": "Canjea el código de seis dígitos y sella la firma: identidad verificada por correo, sello de tiempo del servidor, IP, agente y huella SHA-256 del PDF EXACTO que se firmó, que queda archivado sin tocar. Acepta o rechaza según `decision`; en ambos casos queda la misma evidencia. La firma manuscrita (trazo del lienzo) es opcional y no sustituye al código.\n\n`401` con código `invalid_signature_code` si el código no coincide, caducó o ya se usó — el mismo error en los tres casos, para no decirle a quien prueba si va por buen camino. `409` si el documento ya está firmado.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DocumentSignIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Firma registrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicDocumentSignature"
                }
              }
            }
          },
          "401": {
            "description": "Código inválido, caducado o ya usado (`invalid_signature_code`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido, revocado o caducado (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "El documento ya fue firmado o decidido (`document_already_signed`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido, trazo que no es un PNG real (`signature_image_invalid`) o documento caducado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiados intentos (`too_many_requests`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/signatures/{token}/pdf": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token de compartición del documento.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "downloadSignedDocumentPdf",
        "tags": [
          "quotes"
        ],
        "summary": "Descargar el PDF firmado",
        "description": "Devuelve BYTE A BYTE el PDF que se archivó en el momento de firmar, con la constancia de firma electrónica al pie. No se vuelve a generar: es el documento cuya huella consta en la evidencia, así que su SHA-256 tiene que seguir coincidiendo años después. `404` mientras el documento no esté firmado.",
        "security": [],
        "responses": {
          "200": {
            "description": "PDF firmado.",
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido o documento sin firmar (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones (`too_many_requests`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/quotes/{quote_id}/signature": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "quote_id",
          "in": "path",
          "required": true,
          "description": "UUID del presupuesto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getQuoteSignature",
        "tags": [
          "quotes"
        ],
        "summary": "Ver la evidencia de firma de un presupuesto",
        "description": "Expediente completo de la firma (quién, cuándo, desde dónde y sobre qué huella). Requiere admin+, el mismo umbral con el que se ven los presupuestos. `404` si el presupuesto no existe en la organización o todavía no se ha firmado.",
        "responses": {
          "200": {
            "description": "Evidencia de la firma.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DocumentSignature"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Presupuesto inexistente o sin firmar (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/copilot/chat": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "copilotChat",
        "tags": [
          "copilot"
        ],
        "summary": "Conversar con el copiloto IA",
        "description": "Envía el hilo completo de mensajes al copiloto y obtiene su respuesta. Requiere ser miembro de la organización (member+). El copiloto puede invocar herramientas internas para responder; dichas invocaciones se devuelven en `tool_calls`. Responde `503` si el copiloto no está configurado en el servidor (falta `ANTHROPIC_API_KEY` o está deshabilitado).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CopilotChatIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Respuesta del copiloto.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CopilotChat"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente o usuario no miembro (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Al empezar una conversación asociada, el `project_id` no existe en la organización o no es visible para esta persona, o el `client_id` no existe o está en la papelera (`not_found`). Se responde antes de llamar al modelo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Cupo de uso de Kern superado (`too_many_requests`). Dos cupos independientes por ventana fija de 60 s —por usuario y por organización— y basta cruzar uno. Fail-open si Redis no responde.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Copiloto no configurado en el servidor: falta `ANTHROPIC_API_KEY` o está deshabilitado (`copilot_not_configured`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/copilot/alerts": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "copilotAlerts",
        "tags": [
          "copilot"
        ],
        "summary": "Listar avisos del copiloto Kern",
        "description": "Devuelve los avisos accionables que el copiloto Kern ha detectado para la organización (facturas vencidas, presupuestos por caducar, tareas atrasadas, etc.), agrupados por módulo y con su nivel de severidad. Requiere ser miembro de la organización (member+).",
        "responses": {
          "200": {
            "description": "Lista de avisos del copiloto Kern.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/KernAlert"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente o usuario no miembro (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/copilot/conversations": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "copilotConversations",
        "tags": [
          "copilot"
        ],
        "summary": "Listar conversaciones guardadas del copiloto Kern",
        "description": "Devuelve las conversaciones que el propio usuario ha mantenido con el copiloto Kern en la organización, de más reciente a más antigua. Requiere ser miembro de la organización (member+).\n\n`project_id` y `client_id` acotan a las asociadas a ese proyecto o a ese cliente: es lo que pinta la pestaña «Kern» de una ficha. Siguen siendo SOLO las del propio usuario; asociar no comparte.",
        "parameters": [
          {
            "name": "project_id",
            "in": "query",
            "required": false,
            "description": "Solo las conversaciones asociadas a este proyecto (UUID). Omitir para devolver todas.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "client_id",
            "in": "query",
            "required": false,
            "description": "Solo las conversaciones asociadas a este cliente (UUID). Omitir para devolver todas.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Busca en el TÍTULO y en el CONTENIDO de los mensajes.\n\nQue entre el contenido no es un extra: el título lo pone Kern con las primeras palabras de la conversación, y casi nunca es lo que uno recuerda de ella. Buscando solo en el título, el buscador parecía roto (KERN-21).\n\nEl contenido se pregunta con un EXISTS correlacionado, no filtrando el join que cuenta los mensajes: si no, `message_count` pasaría a contar «los que casan» en vez de «los que hay», y una conversación de cuarenta mensajes con una coincidencia se anunciaría como de uno. En blanco equivale a no mandarlo.",
            "schema": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 200
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de conversaciones del usuario.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CopilotConversation"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente o usuario no miembro (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/copilot/conversations/{conversation_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "conversation_id",
          "in": "path",
          "required": true,
          "description": "UUID de la conversación.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "copilotConversation",
        "tags": [
          "copilot"
        ],
        "summary": "Obtener los mensajes de una conversación del copiloto Kern",
        "description": "Devuelve los mensajes guardados de una conversación del propio usuario, en orden cronológico, para reconstruir el hilo. Requiere ser miembro de la organización (member+).",
        "responses": {
          "200": {
            "description": "Mensajes de la conversación.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CopilotConvMessage"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente o usuario no miembro (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización o conversación no encontrada (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateCopilotConversation",
        "tags": [
          "copilot"
        ],
        "summary": "Mover una conversación de carpeta, o asociarla a un proyecto o cliente",
        "description": "Actualización parcial de la conversación: un campo omitido no se toca y `null` lo vacía. `folder_id` con el uuid de una carpeta TUYA (KERN-8), o `null` para devolverla a la lista suelta; `project_id` y `client_id` con el proyecto o el cliente del que habla, o `null` para desasociarla.\n\nUna carpeta de otra persona responde 404 y no 403, igual que su conversación: un 403 confirmaría que ese id existe y de quién es. El proyecto pasa por la misma puerta que su ficha (404 si no existe o no lo ves) y el cliente por la suya (403 si tu rol no ve la cartera; 404 si no existe o está en la papelera).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CopilotConversationUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Conversación actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CopilotConversation"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permiso para usar Kern en esta organización (`forbidden`), que lo decide la misma guarda que el resto del módulo; o, al mandar `client_id`, un rol que no ve la cartera de clientes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Conversación, carpeta u organización inexistente, usuario no miembro, alguna de las dos es de OTRA persona, o el proyecto/cliente no existe en la organización o no es visible para esta persona (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/copilot/folders": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listCopilotFolders",
        "tags": [
          "copilot"
        ],
        "summary": "Listar mis carpetas de conversaciones",
        "description": "Las carpetas del usuario autenticado en esta organización, en su orden (`position` ascendente, y el `id` como desempate para que dos carpetas con la misma posición no bailen entre dos peticiones).\n\nNo pagina, y es de las «acotadas por naturaleza»: las carpetas las crea una persona a mano, de una en una, y nadie mantiene doscientas. El número de conversaciones crece con el USO diario; el de carpetas, con la adopción — que es el criterio de entrada a `ACOTADAS_POR_NATURALEZA` en `app/core/tests/test_listados_con_tope.py`, donde queda registrada con su motivo.",
        "responses": {
          "200": {
            "description": "Lista de carpetas (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CopilotFolder"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permiso para usar Kern en esta organización (`forbidden`). Lo decide `ensure_can_use_copilot`, la misma guarda que el resto del módulo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createCopilotFolder",
        "tags": [
          "copilot"
        ],
        "summary": "Crear una carpeta de conversaciones",
        "description": "Crea una carpeta a nombre del usuario autenticado. El dueño sale de la sesión y la `position` la pone el servidor al final de las suyas; el cuerpo no admite ninguna de las dos.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CopilotFolderCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Carpeta creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CopilotFolder"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permiso para usar Kern en esta organización (`forbidden`). Lo decide `ensure_can_use_copilot`, la misma guarda que el resto del módulo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`): nombre en blanco o más largo de 60.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/copilot/folders/{folder_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "folder_id",
          "in": "path",
          "required": true,
          "description": "UUID de la carpeta.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "updateCopilotFolder",
        "tags": [
          "copilot"
        ],
        "summary": "Renombrar o mover una carpeta",
        "description": "Actualización parcial: renombrarla o cambiarla de sitio.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CopilotFolderUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Carpeta actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CopilotFolder"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permiso para usar Kern en esta organización (`forbidden`). Lo decide `ensure_can_use_copilot`, la misma guarda que el resto del módulo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Carpeta u organización inexistente, usuario no miembro, o la carpeta es de OTRA persona (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteCopilotFolder",
        "tags": [
          "copilot"
        ],
        "summary": "Eliminar una carpeta",
        "description": "Borra la carpeta. Sus conversaciones NO se borran: vuelven a la lista suelta (`folder_id` a `null`). Borrar en cascada convertiría «ya no quiero esta agrupación» en «bórrame veinte conversaciones», que es trabajo de la persona desapareciendo de refilón — y sin avisar, porque el gesto que pulsó hablaba de la carpeta, no de lo que hay dentro.",
        "responses": {
          "204": {
            "description": "Carpeta eliminada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permiso para usar Kern en esta organización (`forbidden`). Lo decide `ensure_can_use_copilot`, la misma guarda que el resto del módulo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Carpeta u organización inexistente, usuario no miembro, o la carpeta es de otra persona (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/clients/{client_id}/portal-links": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "client_id",
          "in": "path",
          "required": true,
          "description": "UUID del cliente.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listPortalLinks",
        "tags": [
          "finance"
        ],
        "summary": "Listar enlaces de portal del cliente",
        "description": "Enlaces de portal creados para el cliente; requiere ser miembro. El token raw nunca se expone al listar (solo se devuelve al crearlo).",
        "responses": {
          "200": {
            "description": "Lista de enlaces de portal (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PortalLink"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Cliente u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createPortalLink",
        "tags": [
          "finance"
        ],
        "summary": "Crear enlace de portal del cliente",
        "description": "Genera un token urlsafe aleatorio, almacena únicamente su hash, y devuelve el token raw + URL del portal UNA SOLA VEZ en `token` y `portal_url`. Las llamadas posteriores nunca vuelven a exponer el raw. Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PortalLinkCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Enlace creado (incluye `token` raw y `portal_url`; solo en esta respuesta).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PortalLink"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Cliente u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/clients/{client_id}/portal-links/{link_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "client_id",
          "in": "path",
          "required": true,
          "description": "UUID del cliente.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "link_id",
          "in": "path",
          "required": true,
          "description": "UUID del enlace de portal.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "revokePortalLink",
        "tags": [
          "finance"
        ],
        "summary": "Revocar enlace de portal del cliente",
        "description": "Revoca el enlace de portal; el token deja de dar acceso de inmediato. Requiere admin+.",
        "responses": {
          "204": {
            "description": "Enlace revocado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Enlace, cliente u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/portal/{token}": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token raw urlsafe obtenido al crear el enlace de portal.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getPortalSummary",
        "tags": [
          "finance"
        ],
        "summary": "Ver resumen del portal (sin login)",
        "description": "Devuelve el resumen del portal del cliente (marca de la organización y recuentos de documentos) accesible mediante el token. No requiere autenticación. `404` si el token es inválido, revocado o expirado.",
        "security": [],
        "responses": {
          "200": {
            "description": "Resumen del portal.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PortalSummary"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido, revocado o expirado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones (`too_many_requests`): 20/min por IP en las rutas públicas del portal, ventana fija de 60 s; fail-open si Redis no responde.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/portal/{token}/invoices": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token raw urlsafe obtenido al crear el enlace de portal.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getPortalInvoices",
        "tags": [
          "finance"
        ],
        "summary": "Listar facturas del portal (sin login)",
        "description": "Devuelve las facturas visibles en el portal del cliente accesible mediante el token. No requiere autenticación. `404` si el token es inválido, revocado o expirado.",
        "security": [],
        "responses": {
          "200": {
            "description": "Lista de facturas del portal (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PortalInvoice"
                  }
                }
              }
            }
          },
          "404": {
            "description": "Token inválido, revocado o expirado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones (`too_many_requests`): 20/min por IP en las rutas públicas del portal, ventana fija de 60 s; fail-open si Redis no responde.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/portal/{token}/invoices/{invoice_number}/approve": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token raw urlsafe obtenido al crear el enlace de portal.",
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "invoice_number",
          "in": "path",
          "required": true,
          "description": "Número de la factura a aprobar.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "approvePortalInvoice",
        "tags": [
          "finance"
        ],
        "summary": "Aprobar factura del portal (sin login)",
        "description": "Registra la aprobación de la factura por el cliente (firmante, IP y huella de auditoría) desde el portal. No requiere autenticación. `404` si el token es inválido, revocado o expirado, o la factura no existe en el portal. `409` si la factura ya no admite aprobación en su estado actual (p. ej. ya aprobada). `429` si se exceden los límites de peticiones.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PortalApprove"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Factura aprobada (vista pública actualizada).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PortalInvoice"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido, revocado o expirado, o factura inexistente en el portal.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "La factura ya no admite aprobación en su estado actual.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/portal/{token}/quotes": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token raw urlsafe obtenido al crear el enlace de portal.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getPortalQuotes",
        "tags": [
          "finance"
        ],
        "summary": "Listar presupuestos del portal (sin login)",
        "description": "Devuelve los presupuestos visibles en el portal del cliente accesible mediante el token. No requiere autenticación. `404` si el token es inválido, revocado o expirado.",
        "security": [],
        "responses": {
          "200": {
            "description": "Lista de presupuestos del portal (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PortalQuote"
                  }
                }
              }
            }
          },
          "404": {
            "description": "Token inválido, revocado o expirado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones (`too_many_requests`): 20/min por IP en las rutas públicas del portal, ventana fija de 60 s; fail-open si Redis no responde.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/portal/{token}/contracts": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token raw urlsafe obtenido al crear el enlace de portal.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getPortalContracts",
        "tags": [
          "finance"
        ],
        "summary": "Listar contratos del portal (sin login)",
        "description": "Devuelve los contratos visibles en el portal del cliente accesible mediante el token. No requiere autenticación. `404` si el token es inválido, revocado o expirado.",
        "security": [],
        "responses": {
          "200": {
            "description": "Lista de contratos del portal (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PortalContract"
                  }
                }
              }
            }
          },
          "404": {
            "description": "Token inválido, revocado o expirado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones (`too_many_requests`): 20/min por IP en las rutas públicas del portal, ventana fija de 60 s; fail-open si Redis no responde.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/portal/{token}/contracts/{contract_number}/sign": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token raw urlsafe obtenido al crear el enlace de portal.",
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "contract_number",
          "in": "path",
          "required": true,
          "description": "Número del contrato a firmar.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "signPortalContract",
        "tags": [
          "finance"
        ],
        "summary": "Firmar contrato del portal (sin login)",
        "description": "Registra la firma del contrato por el cliente (firmante, IP y huella de auditoría) desde el portal. No requiere autenticación. `404` si el token es inválido, revocado o expirado, o el contrato no existe en el portal. `409` si el contrato ya no admite firma en su estado actual (p. ej. ya firmado). `429` si se exceden los límites de peticiones.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PortalApprove"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Contrato firmado (vista pública actualizada).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PortalContract"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido, revocado o expirado, o contrato inexistente en el portal.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "El contrato ya no admite firma en su estado actual.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/portal/{token}/identify": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token raw urlsafe obtenido al crear el enlace de portal.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "requestPortalAccessCode",
        "tags": [
          "portal"
        ],
        "summary": "Pedir el código de acceso (sin login)",
        "description": "Envía un código de un solo uso a la dirección REGISTRADA del contacto. Responde SIEMPRE lo mismo, exista o no ese contacto: contestar distinto convertiría el enlace en un comprobador de quién trabaja en esa empresa. Rate-limited por IP y por enlace.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PortalIdentifyIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Petición procesada (no dice si había a quién mandarle el código).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PortalCodeSent"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido, revocado o expirado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones (`too_many_requests`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/portal/{token}/verify": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token raw urlsafe obtenido al crear el enlace de portal.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "verifyPortalAccessCode",
        "tags": [
          "portal"
        ],
        "summary": "Canjear el código y abrir sesión (sin login)",
        "description": "Verifica el código y abre la sesión de la persona. El token de sesión NO viaja en el cuerpo: sale SOLO en la cookie HttpOnly `portal_session` (`SameSite=Lax`, `Secure` según despliegue, acotada a `/api/v1/public/portal`). Devolverlo en el JSON lo pondría al alcance de cualquier script de la página y desharía lo que la cookie protege.\n\nAcertar cierra las sesiones anteriores de esa persona en ese enlace: es la vía para echar a quien se quedó con la cookie en un equipo prestado.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PortalVerifyIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Sesión abierta. Fija la cookie `portal_session`.",
            "headers": {
              "Set-Cookie": {
                "description": "Cookie HttpOnly `portal_session` con el token opaco de la sesión.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PortalIdentity"
                }
              }
            }
          },
          "401": {
            "description": "Código inválido o caducado (`portal_code_invalid`), o agotados los 5 intentos (`portal_code_exhausted`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido, revocado o expirado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones (`too_many_requests`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/portal/{token}/me": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token raw urlsafe obtenido al crear el enlace de portal.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getPortalIdentity",
        "tags": [
          "portal"
        ],
        "summary": "Ver quién está dentro (sin login)",
        "description": "Identidad de la sesión actual. `401` `portal_identity_required` si no hay cookie válida — la pantalla debe pedir el código.",
        "security": [],
        "responses": {
          "200": {
            "description": "Identidad verificada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PortalIdentity"
                }
              }
            }
          },
          "401": {
            "description": "Sin sesión válida (`portal_identity_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido, revocado o expirado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones (`too_many_requests`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/portal/{token}/session": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token raw urlsafe obtenido al crear el enlace de portal.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "delete": {
        "operationId": "endPortalSession",
        "tags": [
          "portal"
        ],
        "summary": "Cerrar la sesión del portal (sin login)",
        "description": "Revoca la sesión en base de datos Y borra la cookie. Las dos cosas: borrar solo la cookie dejaría la sesión viva para quien tuviera una copia, y revocar solo la fila dejaría al navegador mandando una credencial muerta. Idempotente.",
        "security": [],
        "responses": {
          "204": {
            "description": "Sesión cerrada (o no había ninguna)."
          },
          "404": {
            "description": "Token inválido, revocado o expirado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones (`too_many_requests`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/portal/{token}/request-types": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token raw urlsafe obtenido al crear el enlace de portal.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getPortalRequestTypes",
        "tags": [
          "portal"
        ],
        "summary": "Listar tipos de petición ofrecibles (sin login)",
        "description": "Formularios ACTIVOS de la organización, con sus campos ya resueltos. Requiere sesión de persona: los nombres y campos describen cómo trabaja la organización por dentro, y a partir de aquí la regla del portal es una sola —todo lo de peticiones necesita una persona verificada—.",
        "security": [],
        "responses": {
          "200": {
            "description": "Tipos de petición (puede ser vacío).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PortalRequestType"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Sin sesión válida (`portal_identity_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido, revocado o expirado, o el módulo no está disponible.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones (`too_many_requests`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/portal/{token}/requests": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token raw urlsafe obtenido al crear el enlace de portal.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getPortalRequests",
        "tags": [
          "portal"
        ],
        "summary": "Listar mis peticiones (sin login)",
        "description": "Las peticiones que abrió ESTA persona, no las de toda la empresa. Dentro de un mismo cliente hay peticiones que un compañero no debería leer —la más obvia: una que va sobre él—. El precio, y es real: una petición que el equipo registró por teléfono sin contacto concreto no aparece en el portal de nadie.",
        "security": [],
        "responses": {
          "200": {
            "description": "Peticiones, la más reciente primero (puede ser vacío).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PortalRequest"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Sin sesión válida (`portal_identity_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido, revocado o expirado, o el módulo no está disponible.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones (`too_many_requests`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createPortalRequest",
        "tags": [
          "portal"
        ],
        "summary": "Abrir una petición (sin login)",
        "description": "Crea una petición del tipo elegido a nombre de la persona de la sesión. El solicitante, la prioridad y la cola NO se aceptan del cuerpo.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PortalRequestCreateIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Petición creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PortalRequest"
                }
              }
            }
          },
          "401": {
            "description": "Sin sesión válida (`portal_identity_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido, revocado o expirado, o el módulo no está disponible.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Tipo desactivado (`request_type_inactive`), falta un campo obligatorio (`missing_required_field`), campo que el tipo no pregunta (`unexpected_field`) o demasiadas peticiones seguidas (`portal_request_quota_exceeded`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones (`too_many_requests`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/portal/{token}/requests/{request_id}": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token raw urlsafe obtenido al crear el enlace de portal.",
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "request_id",
          "in": "path",
          "required": true,
          "description": "UUID de la petición.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getPortalRequest",
        "tags": [
          "portal"
        ],
        "summary": "Ver una petición y su conversación (sin login)",
        "description": "La petición y SOLO los comentarios públicos. `404` —y no `403`— si la petición no es de esta persona: a quien prueba ids ajenos hay que responderle lo mismo que a quien pregunta por uno que no existe.",
        "security": [],
        "responses": {
          "200": {
            "description": "Petición y conversación pública.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PortalRequestDetail"
                }
              }
            }
          },
          "401": {
            "description": "Sin sesión válida (`portal_identity_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido o petición inexistente/ajena (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones (`too_many_requests`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/portal/{token}/requests/{request_id}/comments": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token raw urlsafe obtenido al crear el enlace de portal.",
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "request_id",
          "in": "path",
          "required": true,
          "description": "UUID de la petición.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "createPortalRequestComment",
        "tags": [
          "portal"
        ],
        "summary": "Responder en la conversación (sin login)",
        "description": "Añade una respuesta del cliente al hilo de SU petición. Aterriza en el mismo hilo que el equipo ya mira, marcada como pública y firmada por la persona de contacto (no por un usuario de la organización).",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PortalRequestCommentCreateIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Respuesta añadida.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PortalRequestComment"
                }
              }
            }
          },
          "401": {
            "description": "Sin sesión válida (`portal_identity_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido o petición inexistente/ajena (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones (`too_many_requests`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/portal/{token}/requests/{request_id}/attachments": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token raw urlsafe obtenido al crear el enlace de portal.",
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "request_id",
          "in": "path",
          "required": true,
          "description": "UUID de la petición.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getPortalRequestAttachments",
        "tags": [
          "portal"
        ],
        "summary": "Ficheros de la petición (sin login)",
        "description": "Los ficheros que el CLIENTE puso en su petición (portal, adjuntos de su correo, ingesta). Los que subió el EQUIPO a la misma tarea **no** salen, ni en el listado ni escribiendo su id en la descarga: un adjunto no tiene columna de visibilidad y un comentario sí, con defecto interno, así que servirlos todos publicaría el análisis interno de quien atiende sin que nadie lo hubiera decidido. Para compartir un fichero con el cliente se contesta por correo, que ya lleva adjuntos.\n\nNivel 2: exige la cookie de sesión (una PERSONA verificada), no basta el token del enlace (una EMPRESA).",
        "security": [],
        "responses": {
          "200": {
            "description": "Lista de ficheros (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PortalRequestAttachment"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Sin sesión válida (`portal_identity_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido o petición inexistente/ajena (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones (`too_many_requests`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createPortalRequestAttachment",
        "tags": [
          "portal"
        ],
        "summary": "Adjuntar un fichero a la petición (sin login)",
        "description": "Sube un fichero en base64 a SU petición. Aterriza como adjunto de la TAREA que representa la petición, así que el equipo lo ve en la ficha sin nada nuevo.\n\nRegla 18 sin excepciones, porque quien sube aquí es la persona MENOS verificada que toca el producto: 10 MiB, allow-list positiva de extensiones, verificación de los BYTES contra la extensión (un ejecutable renombrado a `.png` se rechaza) y nombre normalizado al basename. Tope de 10 ficheros por petición, contado contra la base de datos — el cupo de Redis es fail-open y no puede ser lo único que impida llenar el almacenamiento desde fuera.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PortalRequestAttachmentIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Fichero adjuntado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PortalRequestAttachment"
                }
              }
            }
          },
          "401": {
            "description": "Sin sesión válida (`portal_identity_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido o petición inexistente/ajena (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "El fichero no pasa (`invalid_attachment`: base64 inválido, extensión fuera de la allow-list, bytes que no cuadran con la extensión, o pasa de 10 MiB) o la petición ya tiene el máximo de ficheros (`portal_attachment_quota_exceeded`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones (`too_many_requests`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/portal/{token}/requests/{request_id}/attachments/{attachment_id}/download": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token raw urlsafe obtenido al crear el enlace de portal.",
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "request_id",
          "in": "path",
          "required": true,
          "description": "UUID de la petición.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "attachment_id",
          "in": "path",
          "required": true,
          "description": "UUID del adjunto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "downloadPortalRequestAttachment",
        "tags": [
          "portal"
        ],
        "summary": "Descargar un fichero de la petición (sin login)",
        "description": "Descarga AUTENTICADA (regla 18): nunca hay una ruta pública a los bytes. Antes de servir se comprueban cuatro cosas encadenadas, y ninguna sobra: que hay una persona verificada detrás de la cookie, que la petición es de ESA persona de ESE cliente de ESA organización, que el adjunto cuelga de ESA petición, y que lo puso el cliente y no el equipo. Sin la tercera, un id de adjunto de la petición de otro cliente pasaría con solo tener una sesión propia; sin la cuarta, el material interno del equipo se descargaría escribiendo su id a mano aunque no salga en ningún listado.\n\n404 en los cuatro fallos: distinguirlos confirmaría que ese fichero existe en otra parte.",
        "security": [],
        "responses": {
          "200": {
            "description": "El fichero.",
            "content": {
              "application/octet-stream": {}
            }
          },
          "401": {
            "description": "Sin sesión válida (`portal_identity_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido, petición ajena o adjunto que no es de esa petición (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones (`too_many_requests`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/invoices/{invoice_id}/approve": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "invoice_id",
          "in": "path",
          "required": true,
          "description": "UUID de la factura.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "approveInvoice",
        "x-tool": {
          "name": "approve_invoice",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Aprobar (emitir) factura",
        "description": "Transición `draft`→`sent`. Idempotente si ya está `sent`. `422` si el estado actual no permite la transición. Requiere admin+.",
        "responses": {
          "200": {
            "description": "Factura tras la acción.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Invoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Factura u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Transición no válida desde el estado actual (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/invoices/{invoice_id}/reject": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "invoice_id",
          "in": "path",
          "required": true,
          "description": "UUID de la factura.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "rejectInvoice",
        "x-tool": {
          "name": "reject_invoice",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Rechazar (anular) factura",
        "description": "Transición a `cancelled` desde `draft`/`sent`/`overdue`. Idempotente si ya está `cancelled`. `422` si el estado actual no permite la transición (p. ej. `paid`). Requiere admin+.",
        "responses": {
          "200": {
            "description": "Factura tras la acción.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Invoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Factura u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Transición no válida desde el estado actual (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/invoices/{invoice_id}/mark-paid": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "invoice_id",
          "in": "path",
          "required": true,
          "description": "UUID de la factura.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "markInvoicePaid",
        "x-tool": {
          "name": "mark_invoice_paid",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Marcar factura como cobrada",
        "description": "Transición a `paid` desde `sent`/`overdue`. Idempotente si ya está `paid`. `422` si el estado actual no permite la transición (p. ej. un `draft` sin emitir). Requiere admin+.",
        "responses": {
          "200": {
            "description": "Factura tras la acción.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Invoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Factura u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Transición no válida desde el estado actual (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/invoices/{invoice_id}/void": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "invoice_id",
          "in": "path",
          "required": true,
          "description": "UUID de la factura.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "voidInvoice",
        "x-tool": {
          "name": "void_invoice",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Anular factura",
        "description": "Transición a `cancelled` desde `draft`/`sent`/`overdue` (una factura cobrada no se puede anular). Idempotente si ya está `cancelled`. `422` si el estado actual no permite la transición. Requiere admin+.",
        "responses": {
          "200": {
            "description": "Factura tras la acción.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Invoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Factura u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Transición no válida desde el estado actual (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/invoices/{invoice_id}/refund": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "invoice_id",
          "in": "path",
          "required": true,
          "description": "UUID de la factura a reembolsar.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "refundInvoice",
        "x-tool": {
          "name": "refund_invoice",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Reembolsar una factura cobrada (total o parcial)",
        "description": "Devuelve dinero de una factura en estado `paid` emitiendo una **factura rectificativa** (RD 1619/2012): un documento propio, de la serie `REC-`, con su numeración correlativa por organización, su fecha y los importes en NEGATIVO. Como es una factura más, entra con el signo correcto en el Modelo 303, en el 347/349 y en el resto de agregados de ingresos.\n\nEl reembolso TOTAL es el caso particular del parcial: se pide omitiendo `amount`. La suma de reembolsos nunca puede superar el total cobrado (`422`), y el invariante se comprueba bajo un bloqueo de fila, así que dos reembolsos simultáneos no pueden pasarse entre los dos.\n\n`422` también si la factura no está `paid` (una no cobrada se anula con `void`), si ya está reembolsada por completo, o si es ella misma una rectificativa. Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/InvoiceRefundIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "La factura RECTIFICATIVA recién emitida (no la original): importes en negativo, estado `paid` y `rectifies_invoice_id` apuntando a la factura reembolsada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Invoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Factura u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Factura no cobrada, ya reembolsada, rectificativa, o importe fuera de rango (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/invoices/{invoice_id}/refunds": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "invoice_id",
          "in": "path",
          "required": true,
          "description": "UUID de la factura.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listInvoiceRefunds",
        "x-tool": {
          "name": "list_invoice_refunds",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Reembolsos de una factura",
        "description": "Las rectificativas emitidas sobre esta factura, con el importe ya devuelto y el que queda por devolver. Requiere admin+ (misma puerta que el resto de finanzas).",
        "responses": {
          "200": {
            "description": "Reembolsos de la factura.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InvoiceRefunds"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Factura u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/expenses": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listExpenses",
        "x-tool": {
          "name": "list_expenses",
          "domain": "finance",
          "profile": "core",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Listar gastos",
        "description": "Gastos de la organización; requiere ser miembro. Filtrable por `status`, `category`, `project_id` (el proyecto IMPUTADO), rango de `date` (`date_from`/`date_to`) y texto (`search`, contra `description` o `vendor`, case-insensitive). Ordenable por `sort` + `dir`. Sin parámetros mantiene el orden por defecto (fecha ascendente).",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filtra por estado exacto.",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "approved",
                "rejected",
                "paid",
                "void"
              ]
            }
          },
          {
            "name": "category",
            "in": "query",
            "required": false,
            "description": "Filtra por categoría.",
            "schema": {
              "type": "string",
              "enum": [
                "office",
                "travel",
                "software",
                "marketing",
                "payroll",
                "other",
                "hardware",
                "hosting",
                "telecom",
                "subscriptions",
                "professional_services",
                "taxes",
                "insurance",
                "banking",
                "supplies",
                "training",
                "meals",
                "rent",
                "utilities",
                "shipping",
                "legal",
                "advertising",
                "maintenance",
                "fees"
              ]
            }
          },
          {
            "name": "project_id",
            "in": "query",
            "required": false,
            "description": "Filtra por el proyecto IMPUTADO — el mismo `project_id` que se guarda al crear o editar el gasto.\n\nGemelo exacto del `project_id` de `GET /invoices`, con su misma puerta (`require_project`) y su misma respuesta: un proyecto propio o ajeno-compartido-aceptado vale; cualquier otro es `404 not_found`, nunca una lista vacía. La visibilidad del proyecto tampoco entra aquí, por la misma razón y con la misma advertencia que allí: hoy no cambiaría ninguna respuesta porque leer finanzas ya exige `admin`.\n\nNo hay valor para «sin imputar»: pedir los gastos SIN proyecto necesitaría un parámetro propio y explícito, que hoy no existe.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "date_from",
            "in": "query",
            "required": false,
            "description": "`date` >= (fecha ISO 8601).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "date_to",
            "in": "query",
            "required": false,
            "description": "`date` <= (fecha ISO 8601).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "description": "Busca en `description` o `vendor` (case-insensitive).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "Campo de ordenación.",
            "schema": {
              "type": "string",
              "default": "date",
              "enum": [
                "date",
                "number",
                "description",
                "category",
                "amount",
                "status"
              ]
            }
          },
          {
            "name": "dir",
            "in": "query",
            "required": false,
            "description": "Sentido de la ordenación.",
            "schema": {
              "type": "string",
              "default": "asc",
              "enum": [
                "asc",
                "desc"
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de gastos (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de gastos que cumplen los filtros activos (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Expense"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente, usuario no miembro, o `project_id` que esta organización no puede imputar (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros de filtro/orden inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createExpense",
        "x-tool": {
          "name": "create_expense",
          "domain": "finance",
          "profile": "core",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Crear gasto",
        "description": "Crea un gasto en la organización (status inicial `pending`).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "category",
                  "description",
                  "amount",
                  "date"
                ],
                "properties": {
                  "category": {
                    "type": "string",
                    "enum": [
                      "office",
                      "travel",
                      "software",
                      "marketing",
                      "payroll",
                      "other",
                      "hardware",
                      "hosting",
                      "telecom",
                      "subscriptions",
                      "professional_services",
                      "taxes",
                      "insurance",
                      "banking",
                      "supplies",
                      "training",
                      "meals",
                      "rent",
                      "utilities",
                      "shipping",
                      "legal",
                      "advertising",
                      "maintenance",
                      "fees"
                    ]
                  },
                  "description": {
                    "type": "string"
                  },
                  "amount": {
                    "type": "number"
                  },
                  "date": {
                    "type": "string",
                    "format": "date"
                  },
                  "vendor": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Opcional; puede omitirse o enviarse `null`."
                  },
                  "notes": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Opcional; puede omitirse o enviarse `null`."
                  },
                  "tax_rate": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "description": "Tipo de IVA (0–100) incluido en `amount`. Si se envía sin `tax_amount`, el servidor deriva la cuota: `amount − amount/(1+tipo/100)`."
                  },
                  "tax_amount": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "description": "Cuota de IVA incluida en `amount` (si se conoce exacta)."
                  },
                  "supplier_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Ficha de proveedor a vincular (org-scoped; 404 si no existe). Al vincular, `vendor` se sincroniza con el nombre del proveedor salvo que `vendor` se envíe explícito en el mismo cuerpo. `null` = sin ficha de proveedor."
                  },
                  "supplier_invoice_number": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Opcional; nº/ID de la factura del proveedor (AP, PM-39). Texto libre (máx. 50 caracteres) para conciliar el gasto con la factura recibida."
                  },
                  "kind": {
                    "type": "string",
                    "enum": [
                      "expense",
                      "supplier_invoice"
                    ],
                    "default": "expense",
                    "description": "Tipo de gasto (mig 0134, PJKT-2119). `expense` = gasto normal; `supplier_invoice` = factura recibida de proveedor (AP). Inmutable tras la creación."
                  },
                  "supplier_tax_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "maxLength": 20,
                    "description": "Opcional; NIF/CIF del proveedor (facturas de proveedor). Máx. 20 caracteres."
                  },
                  "due_date": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date",
                    "description": "Opcional; fecha de vencimiento del pago (AP)."
                  },
                  "is_intra_eu": {
                    "type": "boolean",
                    "default": false,
                    "description": "Opcional; operación intracomunitaria con inversión del sujeto pasivo (Modelo 349)."
                  },
                  "project_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Opcional; proyecto a imputar (rentabilidad)."
                  },
                  "cost_center_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Opcional; centro de coste al que se imputa el gasto. El API lo acepta desde el principio y el contrato no lo declaraba, así que el SDK no lo conocía. Se valida contra la organización (PR #438, `finance/references.py`): un centro de coste de OTRA organización responde **404 `not_found`**, no 403 —un recurso ajeno tiene que ser indistinguible de uno inexistente, o el código de error confirmaría que ese id existe en alguna parte—. `null` (u omitido) = sin centro de coste."
                  },
                  "currency": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "minLength": 3,
                    "maxLength": 3,
                    "description": "Divisa del gasto en ISO 4217. Omitida (o `null`) = la divisa base de la organización (`Organization.default_currency`). NO hay conversión: el importe se guarda tal cual en la divisa indicada.",
                    "examples": [
                      "USD"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Gasto creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Expense"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente para gestionar finanzas (`forbidden`) o el plan de la organización no incluye el módulo (`feature_not_in_plan`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/expenses/bulk": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "bulkExpenseAction",
        "tags": [
          "finance"
        ],
        "summary": "Acción en lote sobre gastos",
        "description": "Aplica `approve`/`reject` (desde `pending`), `mark_paid` (desde `approved`), `void` (desde cualquier estado no anulado), `set_supplier`, `set_tax_rate` o `set_category` (asignaciones directas) a varios gastos. `set_supplier` vincula el proveedor de `supplier_id` y sincroniza `vendor`. `set_tax_rate` aplica el tipo de IVA de `tax_rate` y recalcula la cuota `tax_amount` de cada gasto sobre su `amount` (IVA incluido). `set_category` cambia la categoría de `category`. Devuelve un resultado honesto: `processed`, `skipped` y `errors`. Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "ids",
                  "action"
                ],
                "properties": {
                  "ids": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "type": "string",
                      "format": "uuid"
                    }
                  },
                  "action": {
                    "type": "string",
                    "enum": [
                      "approve",
                      "reject",
                      "set_supplier",
                      "set_tax_rate",
                      "set_category",
                      "mark_paid",
                      "void"
                    ]
                  },
                  "supplier_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Requerido cuando `action` es `set_supplier`. UUID del proveedor (org-scoped; 404 si no existe). `null` desvincula el proveedor de todos los gastos del lote."
                  },
                  "tax_rate": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "minimum": 0,
                    "maximum": 100,
                    "description": "Usado cuando `action` es `set_tax_rate`. Tipo de IVA (0-100) aplicado al lote; el servidor recalcula `tax_amount` por gasto sobre su `amount` (IVA incluido). `null` quita el desglose de IVA de todos los gastos del lote."
                  },
                  "category": {
                    "type": "string",
                    "description": "Requerido cuando `action` es `set_category`. Nueva categoría para todos los gastos del lote.",
                    "enum": [
                      "office",
                      "travel",
                      "software",
                      "marketing",
                      "payroll",
                      "other",
                      "hardware",
                      "hosting",
                      "telecom",
                      "subscriptions",
                      "professional_services",
                      "taxes",
                      "insurance",
                      "banking",
                      "supplies",
                      "training",
                      "meals",
                      "rent",
                      "utilities",
                      "shipping",
                      "legal",
                      "advertising",
                      "maintenance",
                      "fees"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Resultado por-elemento de la acción.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BulkActionResult"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/expenses/export": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "exportExpensesCsv",
        "tags": [
          "finance"
        ],
        "summary": "Exportar gastos como CSV",
        "description": "Exporta **todos** los gastos que cumplen los filtros activos (sin paginación) en formato CSV UTF-8. Requiere ser miembro. Columnas: descripcion, categoria, estado, fecha, importe, moneda, proveedor.\n\nAcepta los MISMOS filtros que `GET /expenses`, `project_id` incluido, por la misma razón que su gemelo de facturas: una exportación que se salte un filtro entrega de más, y de más no se nota.",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filtra por estado exacto.",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "approved",
                "rejected",
                "paid",
                "void"
              ]
            }
          },
          {
            "name": "category",
            "in": "query",
            "required": false,
            "description": "Filtra por categoría.",
            "schema": {
              "type": "string",
              "enum": [
                "office",
                "travel",
                "software",
                "marketing",
                "payroll",
                "other",
                "hardware",
                "hosting",
                "telecom",
                "subscriptions",
                "professional_services",
                "taxes",
                "insurance",
                "banking",
                "supplies",
                "training",
                "meals",
                "rent",
                "utilities",
                "shipping",
                "legal",
                "advertising",
                "maintenance",
                "fees"
              ]
            }
          },
          {
            "name": "project_id",
            "in": "query",
            "required": false,
            "description": "Filtra por el proyecto IMPUTADO, con la misma puerta y la misma respuesta que en `GET /expenses`: propio o ajeno-compartido-aceptado, y cualquier otro `404 not_found`.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "date_from",
            "in": "query",
            "required": false,
            "description": "`date` >= (fecha ISO 8601).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "date_to",
            "in": "query",
            "required": false,
            "description": "`date` <= (fecha ISO 8601).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "description": "Busca en `description` o `vendor` (case-insensitive).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Archivo CSV con cabecera (UTF-8).",
            "headers": {
              "Content-Disposition": {
                "description": "attachment; filename=\"gastos.csv\"",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente, usuario no miembro, o `project_id` que esta organización no puede imputar (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros de filtro inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/expenses/by-number/{number}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "number",
          "in": "path",
          "required": true,
          "description": "Número secuencial del gasto (entero; p. ej. 42 para EXP-0042).",
          "schema": {
            "type": "integer"
          }
        }
      ],
      "get": {
        "operationId": "getExpenseByNumber",
        "x-tool": {
          "name": "get_expense_by_number",
          "domain": "finance",
          "profile": "core",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Detalle de gasto por número legible",
        "description": "Devuelve el gasto identificado por su número secuencial dentro de la organización. Equivale a `GET /expenses/{expense_id}` pero acepta el número visible en lugar del UUID. `404` si el número no pertenece a la organización.",
        "responses": {
          "200": {
            "description": "Gasto.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Expense"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Número de gasto no encontrado en la organización (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/expenses/{expense_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "expense_id",
          "in": "path",
          "required": true,
          "description": "UUID del gasto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getExpense",
        "x-tool": {
          "name": "get_expense",
          "domain": "finance",
          "profile": "core",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Detalle de gasto",
        "description": "Devuelve el gasto si pertenece a la organización y el usuario es miembro.",
        "responses": {
          "200": {
            "description": "Gasto.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Expense"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Gasto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateExpense",
        "x-tool": {
          "name": "update_expense",
          "domain": "finance",
          "profile": "core",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Actualizar gasto",
        "description": "Actualización parcial; todos los campos son opcionales.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "pending",
                      "approved",
                      "rejected",
                      "paid",
                      "void"
                    ]
                  },
                  "category": {
                    "type": "string",
                    "enum": [
                      "office",
                      "travel",
                      "software",
                      "marketing",
                      "payroll",
                      "other",
                      "hardware",
                      "hosting",
                      "telecom",
                      "subscriptions",
                      "professional_services",
                      "taxes",
                      "insurance",
                      "banking",
                      "supplies",
                      "training",
                      "meals",
                      "rent",
                      "utilities",
                      "shipping",
                      "legal",
                      "advertising",
                      "maintenance",
                      "fees"
                    ]
                  },
                  "description": {
                    "type": "string"
                  },
                  "amount": {
                    "type": "number"
                  },
                  "date": {
                    "type": "string",
                    "format": "date"
                  },
                  "vendor": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra el proveedor."
                  },
                  "notes": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra las notas."
                  },
                  "tax_rate": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "description": "Tipo de IVA (0–100). Enviado sin `tax_amount`, el servidor recalcula la cuota sobre el `amount` efectivo; `null` borra el desglose completo."
                  },
                  "tax_amount": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "description": "Cuota de IVA incluida en `amount`; `null` la borra."
                  },
                  "supplier_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Vincula una ficha de proveedor (org-scoped; 404 si no existe). Al vincular con un proveedor distinto al actual, `vendor` se sincroniza con el nombre del proveedor salvo que `vendor` venga explícito en este PATCH. `null` desvincula sin tocar `vendor`."
                  },
                  "supplier_invoice_number": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Nº/ID de la factura del proveedor (AP, PM-39). Texto libre (máx. 50 caracteres); `null` lo borra."
                  },
                  "supplier_tax_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "maxLength": 20,
                    "description": "NIF/CIF del proveedor (facturas de proveedor). Máx. 20 caracteres; `null` lo borra."
                  },
                  "due_date": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date",
                    "description": "Fecha de vencimiento del pago (AP); `null` la borra."
                  },
                  "is_intra_eu": {
                    "type": [
                      "boolean",
                      "null"
                    ],
                    "description": "Operación intracomunitaria (Modelo 349). `null` = sin cambio (no se modifica el valor actual). `kind` es inmutable y no se admite en el PATCH."
                  },
                  "project_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Proyecto a imputar (rentabilidad). `null` lo desasocia."
                  },
                  "cost_center_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Centro de coste al que se imputa el gasto; `null` lo desasocia. Se valida contra la organización (PR #438, `finance/references.py`): un centro de coste de OTRA organización responde **404 `not_found`**, no 403 (indistinguible de uno inexistente). Hasta el PR #438 esta vía era un 500 incluso con un centro de coste PROPIO —el `UUID` no se convertía al `CHAR(36)` de la columna—, así que el campo no se pudo asignar nunca por PATCH."
                  },
                  "currency": {
                    "type": "string",
                    "minLength": 3,
                    "maxLength": 3,
                    "description": "Divisa del gasto en ISO 4217. NO hay conversión: cambiarla REETIQUETA el importe, no lo recalcula (un gasto capturado en la divisa equivocada se corrige aquí en vez de borrarse y volverse a crear). No admite `null`.",
                    "examples": [
                      "USD"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Gasto actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Expense"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente para gestionar finanzas (`forbidden`) o el plan de la organización no incluye el módulo (`feature_not_in_plan`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Gasto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteExpense",
        "x-tool": {
          "name": "delete_expense",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Eliminar gasto",
        "description": "Elimina el gasto de la organización. Un gasto APROBADO solo puede eliminarlo un `owner`/`admin`; pendiente o rechazado basta el permiso de gestión financiera.",
        "responses": {
          "204": {
            "description": "Gasto eliminado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`): gestión financiera requiere admin+, y eliminar un gasto aprobado exige owner/admin.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Gasto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/expenses/{expense_id}/approve": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "expense_id",
          "in": "path",
          "required": true,
          "description": "UUID del gasto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "approveExpense",
        "x-tool": {
          "name": "approve_expense",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Aprobar gasto",
        "description": "Transición `pending`→`approved`. Idempotente si ya está `approved`. `422` si el estado actual no permite la transición. Requiere admin+.",
        "responses": {
          "200": {
            "description": "Gasto tras la acción.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Expense"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Gasto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Transición no válida desde el estado actual (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/expenses/{expense_id}/reject": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "expense_id",
          "in": "path",
          "required": true,
          "description": "UUID del gasto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "rejectExpense",
        "x-tool": {
          "name": "reject_expense",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Rechazar gasto",
        "description": "Transición `pending`→`rejected`. Idempotente si ya está `rejected`. `422` si el estado actual no permite la transición. Requiere admin+.",
        "responses": {
          "200": {
            "description": "Gasto tras la acción.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Expense"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Gasto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Transición no válida desde el estado actual (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/expenses/{expense_id}/mark-paid": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "expense_id",
          "in": "path",
          "required": true,
          "description": "UUID del gasto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "markPaidExpense",
        "tags": [
          "finance"
        ],
        "summary": "Marcar gasto/factura de proveedor como pagado",
        "description": "Transición `approved`→`paid` (ciclo de vida de factura de proveedor, AP). Idempotente si ya está `paid`. `422` si el estado actual no permite la transición. Requiere admin+.",
        "responses": {
          "200": {
            "description": "Gasto tras la acción.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Expense"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Gasto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Transición no válida desde el estado actual (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/expenses/{expense_id}/void": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "expense_id",
          "in": "path",
          "required": true,
          "description": "UUID del gasto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "voidExpense",
        "tags": [
          "finance"
        ],
        "summary": "Anular gasto/factura de proveedor",
        "description": "Transición a `void` desde `pending`/`approved`/`paid`/`rejected` (ciclo de vida de factura de proveedor, AP). Idempotente si ya está `void`. `422` si el estado actual no permite la transición. Requiere admin+.",
        "responses": {
          "200": {
            "description": "Gasto tras la acción.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Expense"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Gasto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Transición no válida desde el estado actual (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/settings": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getFinanceSettings",
        "tags": [
          "finance"
        ],
        "summary": "Leer las preferencias de finanzas",
        "description": "Devuelve las preferencias de finanzas de la organización (hoy: el interruptor de los recordatorios de cobro). Una organización que nunca las tocó responde los valores por defecto con `updated_at: null`; consultarlas no crea nada. Requiere admin+.",
        "responses": {
          "200": {
            "description": "Preferencias vigentes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FinanceSettings"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "put": {
        "operationId": "updateFinanceSettings",
        "tags": [
          "finance"
        ],
        "summary": "Cambiar las preferencias de finanzas",
        "description": "Enciende o apaga los recordatorios de cobro automáticos. Con el interruptor encendido, un barrido diario escribe a los clientes con facturas vencidas a los 1, 7 y 15 días del vencimiento. Requiere admin+ y queda registrado en auditoría: no es una preferencia personal, es una política sobre lo que sale a nombre de la empresa.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FinanceSettingsUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Preferencias actualizadas.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FinanceSettings"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/summary": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "from",
          "in": "query",
          "required": false,
          "description": "Inicio del rango (inclusive), fecha ISO 8601.",
          "schema": {
            "type": "string",
            "format": "date"
          }
        },
        {
          "name": "to",
          "in": "query",
          "required": false,
          "description": "Fin del rango (inclusive), fecha ISO 8601.",
          "schema": {
            "type": "string",
            "format": "date"
          }
        }
      ],
      "get": {
        "operationId": "getFinanceSummary",
        "x-tool": {
          "name": "finance_summary",
          "domain": "finance",
          "profile": "core",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Resumen financiero",
        "description": "Agregados de facturas y gastos de la organización en el rango `from`–`to`; requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Resumen financiero.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FinanceSummary"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros de rango inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/trend": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "from",
          "in": "query",
          "required": false,
          "description": "Inicio del rango (inclusive), fecha ISO 8601. Por defecto 12 meses atrás.",
          "schema": {
            "type": "string",
            "format": "date"
          }
        },
        {
          "name": "to",
          "in": "query",
          "required": false,
          "description": "Fin del rango (inclusive), fecha ISO 8601. Por defecto hoy.",
          "schema": {
            "type": "string",
            "format": "date"
          }
        }
      ],
      "get": {
        "operationId": "financeTrend",
        "x-tool": {
          "name": "finance_trend",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Serie mensual ingresos/gastos",
        "description": "Serie mensual (`YYYY-MM`) con ingresos (facturas `sent`/`paid`/`overdue` por `issue_date`) y gastos (por `date`). El gasto es el COSTE del mes con el mismo criterio que `GET /finance/summary`: gastos normales aprobados más facturas de proveedor recibidas (todo estado salvo `rejected`/`void`), sin contar dos veces el gasto que anota una factura de proveedor que existe. Antes dejaba fuera las facturas de proveedor y la serie sumaba menos que la tarjeta que la titula. Incluye todos los meses del rango, con ceros donde no hay datos.",
        "responses": {
          "200": {
            "description": "Serie mensual (ascendente por mes).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/FinanceTrendPoint"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros de rango inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/trend/{month}/invoices": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "month",
          "in": "path",
          "required": true,
          "description": "Mes `YYYY-MM` del punto de la tendencia que se abre. Tiene que caer DENTRO del rango `from`/`to`; si no, `422` — un mes fuera del rango no es un punto de esta serie, y contestar 0 € sería enseñar una cifra falsa con cara de correcta.",
          "schema": {
            "type": "string",
            "pattern": "^\\d{4}-(0[1-9]|1[0-2])$",
            "examples": [
              "2026-03"
            ]
          }
        }
      ],
      "get": {
        "operationId": "financeTrendInvoices",
        "tags": [
          "finance"
        ],
        "summary": "Facturas que componen lo facturado de un mes",
        "description": "Desglose de `income` del punto `month` de `GET /finance/trend`: las facturas que lo suman, con su importe, paginadas, y el MISMO criterio con el que se calculó el total (estados, divisa, cotas efectivas del mes y documentos descartados con su motivo).\n\nEl total NO se recalcula por página: `income` sale de la misma consulta agregada que sirve la tendencia, así que `desglose.income` es idéntico al `income` del punto para el mismo `from`/`to` y no se mueve al paginar. Pasa el mismo rango que le pasaste a la tendencia: es lo que RECORTA el primer y el último mes, y sin él el desglose de un mes a medias sumaría más que la barra que explica.\n\nRequiere el mismo rol que ver el agregado (admin+ en la organización), que es también el que ya puede listar facturas una a una: este endpoint no abre ningún dato a quien no lo tuviera.",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "Inicio del rango (inclusive), fecha ISO 8601 — el MISMO que se le pasó a `GET /finance/trend`. Por defecto, 12 meses atrás.",
            "schema": {
              "type": [
                "string",
                "null"
              ],
              "format": "date"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "Fin del rango (inclusive), fecha ISO 8601 — el mismo que se le pasó a `GET /finance/trend`. Por defecto, hoy.",
            "schema": {
              "type": [
                "string",
                "null"
              ],
              "format": "date"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de facturas por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "El desglose del mes. `invoices` puede venir vacío (un mes sin nada facturado, o una página más allá del final); `income` y `criterio` siguen siendo los del punto.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DesgloseDeLoFacturado"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`): ver finanzas es admin+. El desglose enseña documentos individuales, así que exige lo mismo que el agregado y nunca menos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`month` mal formado, `month` fuera del rango `from`/`to`, o parámetros de paginación inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/expenses-by-category": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "from",
          "in": "query",
          "required": false,
          "description": "Inicio del rango (inclusive), fecha ISO 8601. Por defecto 12 meses atrás.",
          "schema": {
            "type": "string",
            "format": "date"
          }
        },
        {
          "name": "to",
          "in": "query",
          "required": false,
          "description": "Fin del rango (inclusive), fecha ISO 8601. Por defecto hoy.",
          "schema": {
            "type": "string",
            "format": "date"
          }
        }
      ],
      "get": {
        "operationId": "financeExpensesByCategory",
        "tags": [
          "finance"
        ],
        "summary": "Gasto por categoría",
        "description": "Total y recuento de gastos por categoría en el rango, de mayor a menor total.",
        "responses": {
          "200": {
            "description": "Agregado por categoría (puede ser vacío).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ExpenseCategoryStat"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros de rango inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/invoice-status-distribution": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "from",
          "in": "query",
          "required": false,
          "description": "Inicio del rango (inclusive), fecha ISO 8601. Por defecto 12 meses atrás.",
          "schema": {
            "type": "string",
            "format": "date"
          }
        },
        {
          "name": "to",
          "in": "query",
          "required": false,
          "description": "Fin del rango (inclusive), fecha ISO 8601. Por defecto hoy.",
          "schema": {
            "type": "string",
            "format": "date"
          }
        }
      ],
      "get": {
        "operationId": "financeInvoiceStatusDistribution",
        "tags": [
          "finance"
        ],
        "summary": "Distribución de facturas por estado",
        "description": "Recuento e importe total de facturas por estado en el rango (por `issue_date`).",
        "responses": {
          "200": {
            "description": "Distribución por estado (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/InvoiceStatusStat"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros de rango inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/aging": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "financeAging",
        "x-tool": {
          "name": "finance_aging",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Aging de cuentas por cobrar",
        "description": "Antigüedad de la deuda de facturas no cobradas (`sent`/`overdue`) troceada por días de retraso frente a `due_date` a fecha de hoy, con los mayores deudores (top 5) y el total pendiente. No usa rango `from`/`to`.",
        "responses": {
          "200": {
            "description": "Informe de aging.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgingReport"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/revenue-by-client": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "from",
          "in": "query",
          "required": false,
          "description": "Inicio del rango (inclusive), fecha ISO 8601. Por defecto 12 meses atrás.",
          "schema": {
            "type": "string",
            "format": "date"
          }
        },
        {
          "name": "to",
          "in": "query",
          "required": false,
          "description": "Fin del rango (inclusive), fecha ISO 8601. Por defecto hoy.",
          "schema": {
            "type": "string",
            "format": "date"
          }
        }
      ],
      "get": {
        "operationId": "financeRevenueByClient",
        "tags": [
          "finance"
        ],
        "summary": "Ingresos por cliente",
        "description": "Agrega los totales de facturas emitidas (`sent`/`paid`/`overdue`) por cliente en el rango `from`–`to`. Agrupa por la ficha de cliente vinculada (`client_id`) cuando existe; si no hay ficha, agrupa por el snapshot de nombre de la factura. Excluye `draft` y `cancelled`. Ordenado de mayor a menor total.",
        "responses": {
          "200": {
            "description": "Agregado por cliente (puede ser vacío).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/RevenueByClientRow"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros de rango inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/profitability/by-project": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "from",
          "in": "query",
          "required": false,
          "description": "Inicio del rango (inclusive), fecha ISO 8601. Por defecto 12 meses atrás.",
          "schema": {
            "type": "string",
            "format": "date"
          }
        },
        {
          "name": "to",
          "in": "query",
          "required": false,
          "description": "Fin del rango (inclusive), fecha ISO 8601. Por defecto hoy.",
          "schema": {
            "type": "string",
            "format": "date"
          }
        }
      ],
      "get": {
        "operationId": "profitabilityByProject",
        "tags": [
          "finance"
        ],
        "summary": "Rentabilidad por proyecto",
        "description": "Rentabilidad de cada proyecto en el rango `from`–`to`: ingresos de facturas emitidas (`sent`/`paid`/`overdue`) imputadas al proyecto, coste de mano de obra (horas registradas), coste de gastos aprobados imputados, coste total, margen y margen porcentual (`null` sin ingresos), además de horas facturables y totales. Requiere admin+.",
        "responses": {
          "200": {
            "description": "Rentabilidad por proyecto (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ProjectProfitability"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o el usuario no es miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`from` o `to` no son fechas ISO 8601 válidas (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/profitability/by-client": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "from",
          "in": "query",
          "required": false,
          "description": "Inicio del rango (inclusive), fecha ISO 8601. Por defecto 12 meses atrás.",
          "schema": {
            "type": "string",
            "format": "date"
          }
        },
        {
          "name": "to",
          "in": "query",
          "required": false,
          "description": "Fin del rango (inclusive), fecha ISO 8601. Por defecto hoy.",
          "schema": {
            "type": "string",
            "format": "date"
          }
        }
      ],
      "get": {
        "operationId": "profitabilityByClient",
        "tags": [
          "finance"
        ],
        "summary": "Rentabilidad por cliente",
        "description": "Roll-up por cliente de la rentabilidad por proyecto en el rango `from`–`to`: agrega `revenue`, `cost` (mano de obra + gastos + AP) y `margin` de `profitabilityByProject` por el `client_id` de cada proyecto (misma fórmula, no la recalcula). Los proyectos sin cliente vinculado se agrupan en una fila \"Sin cliente\" (`client_id: null`) en vez de excluirse. Requiere admin+.",
        "responses": {
          "200": {
            "description": "Rentabilidad por cliente (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ClientProfitability"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o el usuario no es miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`from` o `to` no son fechas ISO 8601 válidas (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/utilization": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "from",
          "in": "query",
          "required": false,
          "description": "Inicio del rango (inclusive), fecha ISO 8601. Por defecto 12 meses atrás.",
          "schema": {
            "type": "string",
            "format": "date"
          }
        },
        {
          "name": "to",
          "in": "query",
          "required": false,
          "description": "Fin del rango (inclusive), fecha ISO 8601. Por defecto hoy.",
          "schema": {
            "type": "string",
            "format": "date"
          }
        }
      ],
      "get": {
        "operationId": "teamUtilization",
        "tags": [
          "finance"
        ],
        "summary": "Utilización del equipo",
        "description": "Utilización de cada miembro del equipo en el rango `from`–`to`: horas facturables, horas totales registradas y porcentaje de utilización (horas facturables sobre el total). Requiere admin+.",
        "responses": {
          "200": {
            "description": "Utilización por miembro (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/TeamUtilization"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o el usuario no es miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`from` o `to` no son fechas ISO 8601 válidas (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/supplier-invoices": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listSupplierInvoices",
        "x-tool": {
          "name": "list_supplier_invoices",
          "domain": "finance",
          "profile": "core",
          "sensitive": true
        },
        "deprecated": true,
        "tags": [
          "finance"
        ],
        "summary": "Listar facturas de proveedor",
        "description": "Obsoleto (PJKT-2119): alias legacy sobre `expenses` (kind=supplier_invoice); shapes sin cambios. Facturas recibidas de proveedores (AP). Requiere ser miembro. Filtrables por `status`, `project_id`, texto en `supplier` (contra `supplier_name` o `number`), rango de `issue_date`. Ordenables.",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "received",
                "approved",
                "rejected",
                "paid",
                "void"
              ]
            }
          },
          {
            "name": "project_id",
            "in": "query",
            "required": false,
            "description": "Filtra por el proyecto IMPUTADO. Es el MISMO parámetro, con el mismo significado, que el de `GET /expenses` —esta operación es un alias sobre esa tabla—: pasa por `require_project`, así que vale un proyecto propio o ajeno-compartido-aceptado y cualquier otro es `404 not_found`.\n\nHasta el 10/09/2026 esta operación lo trataba distinto: llegaba como `str` sin validar, un id que no era UUID contestaba `200 []` en vez de `422`, y un proyecto de otra organización devolvía la lista vacía — exactamente la respuesta que el resto del módulo considera falsa. Se alineó al añadir `?project_id` a los listados de facturas y gastos: el mismo nombre con dos significados dentro del mismo módulo, y encima sobre la misma tabla, es una trampa que solo se paga al depurar.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "supplier",
            "in": "query",
            "required": false,
            "description": "Busca en `supplier_name` o `number` (case-insensitive).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "date_from",
            "in": "query",
            "required": false,
            "description": "`issue_date` >= (fecha ISO 8601).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "date_to",
            "in": "query",
            "required": false,
            "description": "`issue_date` <= (fecha ISO 8601).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "default": "created_at",
              "enum": [
                "created_at",
                "issue_date",
                "due_date",
                "total",
                "supplier",
                "status"
              ]
            }
          },
          {
            "name": "dir",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "default": "asc",
              "enum": [
                "asc",
                "desc"
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de facturas de proveedor (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de facturas de proveedor que cumplen los filtros (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/SupplierInvoice"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente, usuario no miembro, o `project_id` que esta organización no puede imputar.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros inválidos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createSupplierInvoice",
        "x-tool": {
          "name": "create_supplier_invoice",
          "domain": "finance",
          "profile": "core",
          "sensitive": true
        },
        "deprecated": true,
        "tags": [
          "finance"
        ],
        "summary": "Crear factura de proveedor",
        "description": "Obsoleto (PJKT-2119): alias legacy sobre `expenses` (kind=supplier_invoice); shapes sin cambios. Registra una factura recibida de un proveedor. Requiere admin+. Status inicial: `received`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SupplierInvoiceCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Factura de proveedor creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SupplierInvoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permiso (requiere admin+).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/supplier-invoices/bulk": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "bulkSupplierInvoiceAction",
        "deprecated": true,
        "tags": [
          "finance"
        ],
        "summary": "Acción en lote sobre facturas de proveedor",
        "description": "Obsoleto (PJKT-2119): alias legacy sobre `expenses` (kind=supplier_invoice); shapes sin cambios. Aplica una acción a varias facturas de proveedor. Las acciones de estado (approve/reject/mark_paid/void) siguen la máquina de estados por elemento: se omite (`skipped`) la que ya está en el estado destino y se marca error la que no admite la transición desde su estado actual. `delete` elimina las facturas indicadas y requiere `confirm: true`. Devuelve un resultado honesto: `processed` (cambiaron/eliminadas), `skipped` (no-op) y `errors` (por id). Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SupplierInvoiceBulkActionIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Resultado por-elemento de la acción.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BulkActionResult"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido o `confirm` ausente en un `delete` (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/supplier-invoices/{invoice_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "invoice_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getSupplierInvoice",
        "x-tool": {
          "name": "get_supplier_invoice",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "deprecated": true,
        "description": "Obsoleto (PJKT-2119): alias legacy sobre `expenses` (kind=supplier_invoice); shapes sin cambios.",
        "tags": [
          "finance"
        ],
        "summary": "Obtener factura de proveedor",
        "responses": {
          "200": {
            "description": "Factura de proveedor.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SupplierInvoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrada o de otra organización.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateSupplierInvoice",
        "x-tool": {
          "name": "update_supplier_invoice",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "deprecated": true,
        "description": "Obsoleto (PJKT-2119): alias legacy sobre `expenses` (kind=supplier_invoice); shapes sin cambios.",
        "tags": [
          "finance"
        ],
        "summary": "Actualizar factura de proveedor (PATCH parcial)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "PATCH parcial: solo se aplica lo que venga en el cuerpo. Para dejar un campo como está, se OMITE.\n`null` no es «sin cambio»: solo lo admiten los campos cuyo `type` incluye `'null'` aquí abajo (supplier_tax_id, supplier_id, due_date, project_id, cost_center_id, notes) y ahí significa desvincular o borrar el valor. En los demás —columnas NOT NULL que `SupplierInvoice` devuelve siempre— un `null` explícito es un cuerpo inválido y responde **422 `validation_error`** con el envelope. Tres de ellos (`issue_date`, `status`, `is_intra_eu`) devolvían **500** hasta PJKT-2415: el `null` llegaba intacto hasta el `UPDATE`.",
                "properties": {
                  "supplier_name": {
                    "type": "string"
                  },
                  "supplier_tax_id": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "supplier_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Ficha de proveedor a vincular; `null` desvincula. Se valida contra la organización (PR #438, `finance/references.py`): una ficha de OTRA organización responde **404 `not_found`**, no 403 (un recurso ajeno es indistinguible de uno inexistente). A diferencia de los gastos normales, aquí NO se sincroniza `supplier_name` desde la ficha: es el snapshot fiscal de la factura recibida."
                  },
                  "number": {
                    "type": "string"
                  },
                  "issue_date": {
                    "type": "string",
                    "format": "date"
                  },
                  "due_date": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date"
                  },
                  "project_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid"
                  },
                  "cost_center_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Centro de coste al que se imputa; `null` lo desasocia. Se valida contra la organización (PR #438, `finance/references.py`): un centro de coste de OTRA organización responde **404 `not_found`**, no 403. Hasta el PR #438 esta vía era un 500 incluso con un centro PROPIO (el `UUID` no se convertía al `CHAR(36)` de la columna)."
                  },
                  "is_intra_eu": {
                    "type": "boolean",
                    "description": "Operación intracomunitaria (Modelo 349). Se declara aquí desde PJKT-2415, y hasta entonces NO estaba —a propósito, con el motivo escrito en este mismo sitio—: el API lo aceptaba pero un `null` explícito reventaba con **500** (`NOT NULL constraint failed: expenses.is_intra_eu`), así que declararlo nulable habría documentado un 500 como soportado y declararlo no-nulable habría descrito un schema que el API no cumplía.\nAhora sí lo cumple: NO admite `null` (la columna es NOT NULL y `SupplierInvoice` lo devuelve siempre), y mandarlo responde **422 `validation_error`**, igual que `issue_date` y `status` —los otros dos que caían por lo mismo—. Para dejarlo sin cambio, se omite: el PATCH es parcial.",
                    "examples": [
                      true
                    ]
                  },
                  "subtotal": {
                    "type": "number"
                  },
                  "tax": {
                    "type": "number"
                  },
                  "total": {
                    "type": "number"
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "received",
                      "approved",
                      "rejected",
                      "paid",
                      "void"
                    ]
                  },
                  "notes": {
                    "type": [
                      "string",
                      "null"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Factura actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SupplierInvoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permiso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteSupplierInvoice",
        "x-tool": {
          "name": "delete_supplier_invoice",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "deprecated": true,
        "description": "Obsoleto (PJKT-2119): alias legacy sobre `expenses` (kind=supplier_invoice); shapes sin cambios.",
        "tags": [
          "finance"
        ],
        "summary": "Eliminar factura de proveedor",
        "responses": {
          "204": {
            "description": "Eliminada."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/supplier-invoices/{invoice_id}/approve": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "invoice_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "approveSupplierInvoice",
        "x-tool": {
          "name": "approve_supplier_invoice",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "deprecated": true,
        "description": "Obsoleto (PJKT-2119): alias legacy sobre `expenses` (kind=supplier_invoice); shapes sin cambios.",
        "tags": [
          "finance"
        ],
        "summary": "Aprobar factura de proveedor (received → approved)",
        "responses": {
          "200": {
            "description": "Factura aprobada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SupplierInvoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permiso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Transición de estado inválida.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/supplier-invoices/{invoice_id}/reject": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "invoice_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "rejectSupplierInvoice",
        "x-tool": {
          "name": "reject_supplier_invoice",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "deprecated": true,
        "description": "Obsoleto (PJKT-2119): alias legacy sobre `expenses` (kind=supplier_invoice); shapes sin cambios.",
        "tags": [
          "finance"
        ],
        "summary": "Rechazar factura de proveedor",
        "responses": {
          "200": {
            "description": "Factura rechazada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SupplierInvoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permiso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Transición de estado inválida.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/supplier-invoices/{invoice_id}/mark-paid": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "invoice_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "markPaidSupplierInvoice",
        "x-tool": {
          "name": "mark_paid_supplier_invoice",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "deprecated": true,
        "description": "Obsoleto (PJKT-2119): alias legacy sobre `expenses` (kind=supplier_invoice); shapes sin cambios.",
        "tags": [
          "finance"
        ],
        "summary": "Marcar factura de proveedor como pagada",
        "responses": {
          "200": {
            "description": "Factura marcada como pagada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SupplierInvoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permiso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Transición de estado inválida.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/supplier-invoices/{invoice_id}/void": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "invoice_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "voidSupplierInvoice",
        "x-tool": {
          "name": "void_supplier_invoice",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "deprecated": true,
        "description": "Obsoleto (PJKT-2119): alias legacy sobre `expenses` (kind=supplier_invoice); shapes sin cambios.",
        "tags": [
          "finance"
        ],
        "summary": "Anular factura de proveedor",
        "responses": {
          "200": {
            "description": "Factura anulada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SupplierInvoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permiso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Transición de estado inválida.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/ap-aging": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "apAging",
        "x-tool": {
          "name": "ap_aging",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "deprecated": true,
        "tags": [
          "finance"
        ],
        "summary": "Aging de cuentas a pagar (AP)",
        "description": "Obsoleto (PJKT-2119): alias legacy sobre `expenses` (kind=supplier_invoice); shapes sin cambios. Aging de facturas de proveedor no pagadas (`received`, `approved`), agrupadas por tramos de antigüedad frente a `due_date`, con los principales proveedores pendientes de pago.",
        "responses": {
          "200": {
            "description": "Informe de aging AP.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApAgingReport"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/fiscal-export": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "period",
          "in": "query",
          "required": true,
          "description": "Periodo fiscal: año completo (`2026`), trimestre (`2026-Q1` … `2026-Q4`), o mes (`2026-07`).",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "fiscalExport",
        "tags": [
          "finance"
        ],
        "summary": "Exportación fiscal ZIP",
        "description": "Devuelve un ZIP con dos archivos del periodo: `export-fiscal-{period}.xlsx` (libro Excel con 4 hojas: Facturas emitidas, Facturas recibidas, Gastos, Modelo 303) y `resumen-fiscal-{period}.pdf` (PDF corporativo con logo, identidad de la organización, bloque Modelo 303 y tabla-resumen).\n\n**Solo para organizaciones que facturan desde España** (`country = 'ES'`): el libro y el PDF se construyen alrededor del Modelo 303 de la AEAT. Si la organización factura desde otro país, responde `403` `not_available_in_country`.",
        "responses": {
          "200": {
            "description": "ZIP con el XLSX y el PDF del periodo.",
            "content": {
              "application/zip": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permisos de finanzas (`forbidden`), o la organización no factura desde España (`not_available_in_country`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Periodo inválido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/tax/modelo-303": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "period",
          "in": "query",
          "required": true,
          "description": "Periodo: YYYY, YYYY-Qn o YYYY-MM.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getModelo303",
        "tags": [
          "finance"
        ],
        "summary": "Resumen fiscal Modelo 303",
        "description": "Devuelve el resumen de IVA repercutido, IVA soportado y resultado para el periodo indicado (preview antes de descargar el ZIP).\n\n**Solo para organizaciones que facturan desde España** (`country = 'ES'`): el Modelo 303 es la autoliquidación de IVA de la AEAT y su cálculo no significa nada en otra jurisdicción. Si la organización factura desde otro país, responde `403` `not_available_in_country`.",
        "responses": {
          "200": {
            "description": "Resumen Modelo 303.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Modelo303"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permisos de finanzas (`forbidden`), o la organización no factura desde España (`not_available_in_country`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Periodo inválido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/tax/modelo-347": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getModelo347",
        "x-tool": {
          "name": "get_modelo_347",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Modelo 347 — Operaciones con terceros",
        "description": "Informe simplificado Modelo 347: terceros (AR + AP) cuyo total anual supera el umbral legal de 3.005,06 €. Requiere ser miembro.\n\n**Solo para organizaciones que facturan desde España** (`country = 'ES'`): el umbral de 3.005,06 € es una cifra de la ley española y aplicarlo a importes en otra divisa produce un informe plausible y falso. Si la organización factura desde otro país, responde `403` `not_available_in_country`.",
        "parameters": [
          {
            "name": "year",
            "in": "query",
            "required": true,
            "description": "Ejercicio fiscal (ej. 2026).",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Informe Modelo 347.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Modelo347"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permisos de finanzas (`forbidden`), o la organización no factura desde España (`not_available_in_country`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros inválidos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/tax/modelo-349": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getModelo349",
        "x-tool": {
          "name": "get_modelo_349",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Modelo 349 — Operaciones intracomunitarias",
        "description": "Informe simplificado Modelo 349: operaciones con `is_intra_eu = true`. El flag es explícito (no derivado del prefijo NIF-IVA) para garantizar cobertura simétrica entre facturas emitidas y recibidas. Requiere ser miembro.\n\n**Solo para organizaciones que facturan desde España** (`country = 'ES'`): el 349 es la declaración recapitulativa que se presenta ante la AEAT, y «intracomunitario» solo tiene sentido desde dentro de la UE. Si la organización factura desde otro país, responde `403` `not_available_in_country`.",
        "parameters": [
          {
            "name": "year",
            "in": "query",
            "required": true,
            "description": "Ejercicio fiscal (ej. 2026).",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Informe Modelo 349.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Modelo349"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permisos de finanzas (`forbidden`), o la organización no factura desde España (`not_available_in_country`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros inválidos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/budgets": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listBudgets",
        "x-tool": {
          "name": "list_budgets",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Listar presupuestos",
        "description": "Presupuestos de la organización. Filtrables por `period`, `project_id` y `category`. Requiere ser miembro.",
        "parameters": [
          {
            "name": "period",
            "in": "query",
            "required": false,
            "description": "Filtrar por período (YYYY, YYYY-Qn, YYYY-MM).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "project_id",
            "in": "query",
            "required": false,
            "description": "Filtrar por proyecto.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "category",
            "in": "query",
            "required": false,
            "description": "Filtrar por categoría.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de presupuestos.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Budget"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada o sin acceso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createBudget",
        "x-tool": {
          "name": "create_budget",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Crear presupuesto",
        "description": "Crea un presupuesto. Requiere rol admin o superior.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "category",
                  "period",
                  "amount"
                ],
                "properties": {
                  "category": {
                    "type": "string",
                    "maxLength": 20
                  },
                  "period": {
                    "type": "string",
                    "description": "YYYY, YYYY-Qn o YYYY-MM."
                  },
                  "amount": {
                    "type": "number"
                  },
                  "project_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid"
                  },
                  "notes": {
                    "type": [
                      "string",
                      "null"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Presupuesto creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Budget"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Permisos insuficientes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada o sin acceso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Datos inválidos (período malformado, importe negativo, etc.).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/budgets/{budget_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "budget_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getBudget",
        "x-tool": {
          "name": "get_budget",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Obtener presupuesto",
        "responses": {
          "200": {
            "description": "Presupuesto.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Budget"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Presupuesto o organización no encontrados.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateBudget",
        "x-tool": {
          "name": "update_budget",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Actualizar presupuesto",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "amount": {
                    "type": "number"
                  },
                  "notes": {
                    "type": [
                      "string",
                      "null"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Presupuesto actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Budget"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Permisos insuficientes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Presupuesto no encontrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Datos inválidos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteBudget",
        "x-tool": {
          "name": "delete_budget",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Eliminar presupuesto",
        "responses": {
          "204": {
            "description": "Eliminado correctamente."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Presupuesto no encontrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/budget-vs-actual": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "budgetVsActual",
        "x-tool": {
          "name": "budget_vs_actual",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Presupuesto vs. real",
        "description": "Compara el presupuesto con el gasto real del período. El \"real\" suma gastos (expenses no rechazados) + facturas de proveedor (no void/rejected) de la misma categoría y período.",
        "parameters": [
          {
            "name": "period",
            "in": "query",
            "required": true,
            "description": "Período: YYYY, YYYY-Qn o YYYY-MM.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Comparativa presupuesto vs. real.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BudgetVsActual"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Período inválido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/cost-centers": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listCostCenters",
        "x-tool": {
          "name": "list_cost_centers",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Listar centros de coste",
        "description": "Lista plana de centros de coste de la organización. La jerarquía se infiere mediante `parent_id`. Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Lista de centros de coste.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CostCenter"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada o sin acceso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createCostCenter",
        "x-tool": {
          "name": "create_cost_center",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Crear centro de coste",
        "description": "Crea un centro de coste. El `code` debe ser único dentro de la organización. Si se especifica `parent_id`, no puede crear ciclos en el árbol. Requiere rol admin o superior.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "code",
                  "name"
                ],
                "properties": {
                  "code": {
                    "type": "string",
                    "maxLength": 20,
                    "description": "Código único (máx. 20 caracteres)."
                  },
                  "name": {
                    "type": "string"
                  },
                  "parent_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Centro de coste creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CostCenter"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Permisos insuficientes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Código duplicado, ciclo detectado u otros datos inválidos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/cost-centers/{cost_center_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "cost_center_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getCostCenter",
        "x-tool": {
          "name": "get_cost_center",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Obtener centro de coste",
        "responses": {
          "200": {
            "description": "Centro de coste.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CostCenter"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Centro de coste u organización no encontrados.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateCostCenter",
        "x-tool": {
          "name": "update_cost_center",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Actualizar centro de coste",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "parent_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid"
                  },
                  "clear_parent": {
                    "type": "boolean",
                    "default": false,
                    "description": "Si true, elimina el padre (convierte en raíz)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Centro de coste actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CostCenter"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Permisos insuficientes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Centro de coste no encontrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Ciclo detectado u otros datos inválidos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteCostCenter",
        "x-tool": {
          "name": "delete_cost_center",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Eliminar centro de coste",
        "description": "Elimina el centro de coste. Los hijos pasan a huérfanos (parent_id = null) por la regla ON DELETE SET NULL de la FK.",
        "responses": {
          "204": {
            "description": "Eliminado correctamente."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Centro de coste no encontrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/cost-center-rollup": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "costCenterRollup",
        "x-tool": {
          "name": "cost_center_rollup",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Rollup por centro de coste",
        "description": "Totales de gasto agrupados por centro de coste para el período indicado. Incluye expenses, facturas de proveedor y facturas emitidas. Solo centros que tienen al menos un registro en el período aparecen. Requiere ser miembro.",
        "parameters": [
          {
            "name": "period",
            "in": "query",
            "required": true,
            "description": "Período: YYYY, YYYY-Qn o YYYY-MM.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Rollup por centro de coste.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CostCenterRollup"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Período inválido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/fixed-assets": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listFixedAssets",
        "x-tool": {
          "name": "list_fixed_assets",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Listar activos fijos",
        "description": "Lista todos los activos fijos de la organización. Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Lista de activos fijos.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/FixedAsset"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada o sin acceso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createFixedAsset",
        "x-tool": {
          "name": "create_fixed_asset",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Crear activo fijo",
        "description": "Registra un nuevo activo fijo. Requiere rol admin o superior.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "category",
                  "acquisition_date",
                  "acquisition_cost",
                  "useful_life_months"
                ],
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "category": {
                    "type": "string",
                    "maxLength": 50
                  },
                  "asset_type": {
                    "type": "string",
                    "enum": [
                      "fixed",
                      "crypto",
                      "etf"
                    ],
                    "default": "fixed",
                    "description": "Tipo de activo. 'crypto'/'etf' exigen `symbol` y admiten `quantity` para valoración a mercado."
                  },
                  "acquisition_date": {
                    "type": "string",
                    "format": "date"
                  },
                  "acquisition_cost": {
                    "type": "number"
                  },
                  "useful_life_months": {
                    "type": "integer",
                    "minimum": 1
                  },
                  "salvage_value": {
                    "type": "number",
                    "default": 0
                  },
                  "method": {
                    "type": "string",
                    "enum": [
                      "straight_line"
                    ],
                    "default": "straight_line"
                  },
                  "crypto_subtype": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "enum": [
                      "native",
                      "token",
                      "stablecoin",
                      null
                    ],
                    "description": "Subtipo de cripto (solo asset_type='crypto')."
                  },
                  "symbol": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Ticker de mercado (requerido para crypto/etf)."
                  },
                  "quantity": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "description": "Unidades poseídas (para valorar a mercado)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Activo fijo creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FixedAsset"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Permisos insuficientes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Datos inválidos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/fixed-assets/register": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "assetsRegister",
        "x-tool": {
          "name": "assets_register",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Registro de activos fijos",
        "description": "Resumen agregado del registro de activos: totales de coste, amortización acumulada y valor neto contable a la fecha indicada (`as_of`). Requiere ser miembro.",
        "parameters": [
          {
            "name": "as_of",
            "in": "query",
            "required": false,
            "description": "Fecha de referencia (ISO 8601). Por defecto, hoy.",
            "schema": {
              "type": "string",
              "format": "date"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Resumen del registro de activos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AssetsRegisterSummary"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`as_of` no es una fecha ISO 8601 válida (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/fixed-assets/refresh-prices": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "refreshAssetPrices",
        "tags": [
          "finance"
        ],
        "summary": "Actualizar precios de mercado",
        "description": "Refresca el valor de mercado de todos los activos crypto/etf de la organización consultando su proveedor de cotización (CoinGecko para crypto, Stooq para etf). Degrada bien: si un proveedor falla, conserva el valor anterior del activo. Requiere rol admin o superior.",
        "responses": {
          "200": {
            "description": "Resumen de la actualización de precios.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AssetPriceRefresh"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Permisos insuficientes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/fixed-assets/{asset_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "asset_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getFixedAsset",
        "x-tool": {
          "name": "get_fixed_asset",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Obtener activo fijo",
        "responses": {
          "200": {
            "description": "Activo fijo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FixedAsset"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Activo u organización no encontrados.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateFixedAsset",
        "x-tool": {
          "name": "update_fixed_asset",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Actualizar activo fijo",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "category": {
                    "type": "string"
                  },
                  "disposed_date": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date"
                  },
                  "symbol": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Ticker de mercado (crypto/etf)."
                  },
                  "quantity": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "description": "Unidades poseídas."
                  },
                  "crypto_subtype": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "enum": [
                      "native",
                      "token",
                      "stablecoin",
                      null
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Activo fijo actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FixedAsset"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Permisos insuficientes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Activo no encontrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Datos inválidos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteFixedAsset",
        "x-tool": {
          "name": "delete_fixed_asset",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Eliminar activo fijo",
        "responses": {
          "204": {
            "description": "Eliminado correctamente."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Activo no encontrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/fixed-assets/{asset_id}/amortization": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "asset_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "assetAmortization",
        "x-tool": {
          "name": "asset_amortization",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Cuadro de amortización",
        "description": "Cuadro de amortización mensual (línea recta) del activo fijo. `accumulated_depreciation` y `book_value` se calculan a la fecha `as_of`. Requiere ser miembro.",
        "parameters": [
          {
            "name": "as_of",
            "in": "query",
            "required": false,
            "description": "Fecha de referencia (ISO 8601). Por defecto, hoy.",
            "schema": {
              "type": "string",
              "format": "date"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Cuadro de amortización.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AssetAmortization"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Activo u organización no encontrados.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`as_of` no es una fecha ISO 8601 válida (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/finance/fixed-assets/{asset_id}/refresh-price": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "asset_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "refreshAssetPrice",
        "tags": [
          "finance"
        ],
        "summary": "Actualizar precio de un activo",
        "description": "Refresca el valor de mercado de un único activo crypto/etf. Requiere rol admin o superior.",
        "responses": {
          "200": {
            "description": "Resumen de la actualización (un activo).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AssetPriceRefresh"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Permisos insuficientes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Activo u organización no encontrados.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/recurring-invoices": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listRecurringInvoices",
        "tags": [
          "finance"
        ],
        "summary": "Listar facturas recurrentes",
        "description": "Plantillas de factura recurrente de la organización; requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Lista de plantillas (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/RecurringInvoice"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createRecurringInvoice",
        "tags": [
          "finance"
        ],
        "summary": "Crear factura recurrente",
        "description": "Crea una plantilla de factura recurrente (activa por defecto). No genera ninguna factura hasta invocar `generate`. Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "client_name",
                  "items",
                  "frequency",
                  "next_run_date"
                ],
                "properties": {
                  "client_name": {
                    "type": "string"
                  },
                  "client_email": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "email"
                  },
                  "items": {
                    "type": "array",
                    "minItems": 1,
                    "description": "Plantilla de líneas (al menos una).",
                    "items": {
                      "type": "object",
                      "required": [
                        "description",
                        "quantity",
                        "unit_price"
                      ],
                      "properties": {
                        "description": {
                          "type": "string"
                        },
                        "quantity": {
                          "type": "number"
                        },
                        "unit_price": {
                          "type": "number"
                        }
                      }
                    }
                  },
                  "frequency": {
                    "type": "string",
                    "enum": [
                      "weekly",
                      "biweekly",
                      "monthly",
                      "quarterly",
                      "yearly"
                    ]
                  },
                  "next_run_date": {
                    "type": "string",
                    "format": "date"
                  },
                  "tax_rate": {
                    "type": "number",
                    "default": 21
                  },
                  "day_of_month": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "Día del mes (1-28) para cadencias mensuales."
                  },
                  "notes": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "currency": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "minLength": 3,
                    "maxLength": 3,
                    "description": "Divisa ISO 4217 de la plantilla, que HEREDAN las facturas que genere. Omitida (o `null`) = la divisa base de la organización (`Organization.default_currency`). Un cliente que factura en dólares se declara aquí una vez y deja de recibir doce facturas al año en la divisa de la casa. NO hay conversión.",
                    "examples": [
                      "USD"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Plantilla creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RecurringInvoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/recurring-invoices/due": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listDueRecurringInvoices",
        "tags": [
          "finance"
        ],
        "summary": "Facturas recurrentes vencidas",
        "description": "Plantillas activas con `next_run_date` <= hoy (candidatas a generar). Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Plantillas vencidas (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/RecurringInvoice"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/recurring-invoices/{recurring_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "recurring_id",
          "in": "path",
          "required": true,
          "description": "UUID de la plantilla de factura recurrente.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getRecurringInvoice",
        "tags": [
          "finance"
        ],
        "summary": "Detalle de factura recurrente",
        "responses": {
          "200": {
            "description": "Plantilla.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RecurringInvoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Plantilla u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateRecurringInvoice",
        "tags": [
          "finance"
        ],
        "summary": "Actualizar factura recurrente",
        "description": "Actualización parcial; todos los campos son opcionales. Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "client_name": {
                    "type": "string"
                  },
                  "client_email": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "email"
                  },
                  "items": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "type": "object",
                      "required": [
                        "description",
                        "quantity",
                        "unit_price"
                      ],
                      "properties": {
                        "description": {
                          "type": "string"
                        },
                        "quantity": {
                          "type": "number"
                        },
                        "unit_price": {
                          "type": "number"
                        }
                      }
                    }
                  },
                  "frequency": {
                    "type": "string",
                    "enum": [
                      "weekly",
                      "biweekly",
                      "monthly",
                      "quarterly",
                      "yearly"
                    ]
                  },
                  "next_run_date": {
                    "type": "string",
                    "format": "date"
                  },
                  "tax_rate": {
                    "type": "number"
                  },
                  "day_of_month": {
                    "type": [
                      "integer",
                      "null"
                    ]
                  },
                  "notes": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "is_active": {
                    "type": "boolean"
                  },
                  "currency": {
                    "type": "string",
                    "minLength": 3,
                    "maxLength": 3,
                    "description": "Divisa ISO 4217 de la plantilla. Afecta a las facturas que se generen a partir de ahora; las ya emitidas conservan la suya.",
                    "examples": [
                      "USD"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Plantilla actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RecurringInvoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Plantilla u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteRecurringInvoice",
        "tags": [
          "finance"
        ],
        "summary": "Eliminar factura recurrente",
        "description": "Elimina la plantilla. No afecta a las facturas ya generadas. Requiere admin+.",
        "responses": {
          "204": {
            "description": "Plantilla eliminada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Plantilla u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/recurring-invoices/{recurring_id}/toggle": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "recurring_id",
          "in": "path",
          "required": true,
          "description": "UUID de la plantilla.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "toggleRecurringInvoice",
        "tags": [
          "finance"
        ],
        "summary": "Activar/desactivar factura recurrente",
        "description": "Invierte `is_active`. Requiere admin+.",
        "responses": {
          "200": {
            "description": "Plantilla tras el cambio.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RecurringInvoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Plantilla u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/recurring-invoices/{recurring_id}/generate": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "recurring_id",
          "in": "path",
          "required": true,
          "description": "UUID de la plantilla.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "generateRecurringInvoice",
        "tags": [
          "finance"
        ],
        "summary": "Generar factura desde la plantilla",
        "description": "Crea una factura REAL (status `draft`) con los mismos totales/numeración que el alta manual, fija `last_run_date`=hoy y avanza `next_run_date` según la frecuencia. Devuelve la factura creada. Requiere admin+.",
        "responses": {
          "201": {
            "description": "Factura creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Invoice"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Plantilla u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/recurring-expenses": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listRecurringExpenses",
        "tags": [
          "finance"
        ],
        "summary": "Listar gastos recurrentes",
        "description": "Plantillas de gasto recurrente de la organización; requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Lista de plantillas (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/RecurringExpense"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createRecurringExpense",
        "tags": [
          "finance"
        ],
        "summary": "Crear gasto recurrente",
        "description": "Crea una plantilla de gasto recurrente (activa por defecto). No genera ningún gasto hasta invocar `generate`. Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "category",
                  "description",
                  "amount",
                  "frequency",
                  "next_run_date"
                ],
                "properties": {
                  "category": {
                    "type": "string",
                    "enum": [
                      "office",
                      "travel",
                      "software",
                      "marketing",
                      "payroll",
                      "other",
                      "hardware",
                      "hosting",
                      "telecom",
                      "subscriptions",
                      "professional_services",
                      "taxes",
                      "insurance",
                      "banking",
                      "supplies",
                      "training",
                      "meals",
                      "rent",
                      "utilities",
                      "shipping",
                      "legal",
                      "advertising",
                      "maintenance",
                      "fees"
                    ]
                  },
                  "description": {
                    "type": "string"
                  },
                  "amount": {
                    "type": "number"
                  },
                  "frequency": {
                    "type": "string",
                    "enum": [
                      "weekly",
                      "biweekly",
                      "monthly",
                      "quarterly",
                      "yearly"
                    ]
                  },
                  "next_run_date": {
                    "type": "string",
                    "format": "date"
                  },
                  "vendor": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "day_of_month": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "Día del mes (1-28) para cadencias mensuales."
                  },
                  "tax_rate": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "description": "Tipo de IVA (0–100) que HEREDAN los gastos generados desde esta plantilla (la cuota se deriva del importe al generar)."
                  },
                  "currency": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "minLength": 3,
                    "maxLength": 3,
                    "description": "Divisa ISO 4217 de la plantilla, que HEREDAN los gastos que genere. Omitida (o `null`) = la divisa base de la organización (`Organization.default_currency`). Una suscripción facturada en dólares se declara aquí una vez y deja de recodificarse como euros en cada barrido. NO hay conversión.",
                    "examples": [
                      "USD"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Plantilla creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RecurringExpense"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/recurring-expenses/due": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listDueRecurringExpenses",
        "tags": [
          "finance"
        ],
        "summary": "Gastos recurrentes vencidos",
        "description": "Plantillas activas con `next_run_date` <= hoy (candidatas a generar). Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Plantillas vencidas (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/RecurringExpense"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/recurring-expenses/{recurring_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "recurring_id",
          "in": "path",
          "required": true,
          "description": "UUID de la plantilla de gasto recurrente.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getRecurringExpense",
        "tags": [
          "finance"
        ],
        "summary": "Detalle de gasto recurrente",
        "responses": {
          "200": {
            "description": "Plantilla.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RecurringExpense"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Plantilla u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateRecurringExpense",
        "tags": [
          "finance"
        ],
        "summary": "Actualizar gasto recurrente",
        "description": "Actualización parcial; todos los campos son opcionales. Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "category": {
                    "type": "string",
                    "enum": [
                      "office",
                      "travel",
                      "software",
                      "marketing",
                      "payroll",
                      "other",
                      "hardware",
                      "hosting",
                      "telecom",
                      "subscriptions",
                      "professional_services",
                      "taxes",
                      "insurance",
                      "banking",
                      "supplies",
                      "training",
                      "meals",
                      "rent",
                      "utilities",
                      "shipping",
                      "legal",
                      "advertising",
                      "maintenance",
                      "fees"
                    ]
                  },
                  "description": {
                    "type": "string"
                  },
                  "amount": {
                    "type": "number"
                  },
                  "frequency": {
                    "type": "string",
                    "enum": [
                      "weekly",
                      "biweekly",
                      "monthly",
                      "quarterly",
                      "yearly"
                    ]
                  },
                  "next_run_date": {
                    "type": "string",
                    "format": "date"
                  },
                  "vendor": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "day_of_month": {
                    "type": [
                      "integer",
                      "null"
                    ]
                  },
                  "is_active": {
                    "type": "boolean"
                  },
                  "currency": {
                    "type": "string",
                    "minLength": 3,
                    "maxLength": 3,
                    "description": "Divisa ISO 4217 de la plantilla. Afecta a los gastos que se generen A PARTIR de ahora; los ya generados no se tocan. No admite `null`.",
                    "examples": [
                      "USD"
                    ]
                  },
                  "tax_rate": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "description": "Tipo de IVA (0–100) que HEREDAN los gastos generados. `null` = sin desglose, y es un valor con significado: aquí sí se admite. Afecta a los gastos que se generen a partir de ahora.",
                    "examples": [
                      10
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Plantilla actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RecurringExpense"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Plantilla u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteRecurringExpense",
        "tags": [
          "finance"
        ],
        "summary": "Eliminar gasto recurrente",
        "description": "Elimina la plantilla. No afecta a los gastos ya generados. Requiere admin+.",
        "responses": {
          "204": {
            "description": "Plantilla eliminada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Plantilla u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/recurring-expenses/{recurring_id}/toggle": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "recurring_id",
          "in": "path",
          "required": true,
          "description": "UUID de la plantilla.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "toggleRecurringExpense",
        "tags": [
          "finance"
        ],
        "summary": "Activar/desactivar gasto recurrente",
        "description": "Invierte `is_active`. Requiere admin+.",
        "responses": {
          "200": {
            "description": "Plantilla tras el cambio.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RecurringExpense"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Plantilla u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/recurring-expenses/{recurring_id}/generate": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "recurring_id",
          "in": "path",
          "required": true,
          "description": "UUID de la plantilla.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "generateRecurringExpense",
        "tags": [
          "finance"
        ],
        "summary": "Generar gasto desde la plantilla",
        "description": "Crea un gasto REAL (status `pending`), fija `last_run_date`=hoy y avanza `next_run_date` según la frecuencia. Devuelve el gasto creado. Requiere admin+.",
        "responses": {
          "201": {
            "description": "Gasto creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Expense"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Plantilla u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/clients": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listClients",
        "x-tool": {
          "name": "list_clients",
          "domain": "crm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "clients"
        ],
        "summary": "Listar clientes",
        "description": "Clientes de la organización; requiere ser miembro. Filtrable por texto (`q`, contra nombre/email/empresa) y por estado (`is_active`).",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Búsqueda por nombre, email o empresa (case-insensitive).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "is_active",
            "in": "query",
            "required": false,
            "description": "Filtra por estado activo.",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de clientes (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de clientes que cumplen los filtros (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Client"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createClient",
        "x-tool": {
          "name": "create_client",
          "domain": "crm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "clients"
        ],
        "summary": "Crear cliente",
        "description": "Crea un cliente en la organización (`is_active` inicial `true`). Solo `name` es obligatorio; `client_type` por defecto `legal`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "client_type": {
                    "type": "string",
                    "enum": [
                      "legal",
                      "individual"
                    ],
                    "default": "legal"
                  },
                  "logo_url": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "maxLength": 512,
                    "description": "URL del logotipo del cliente. Debe empezar por `http://`, `https://` o `/`. Cadena vacía se trata como `null`."
                  },
                  "email": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Opcional; puede omitirse o enviarse `null`."
                  },
                  "phone": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Opcional; puede omitirse o enviarse `null`."
                  },
                  "company": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Opcional; puede omitirse o enviarse `null`."
                  },
                  "tax_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Opcional; puede omitirse o enviarse `null`."
                  },
                  "address": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Opcional; puede omitirse o enviarse `null`."
                  },
                  "city": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Opcional; puede omitirse o enviarse `null`."
                  },
                  "country": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Opcional; puede omitirse o enviarse `null`."
                  },
                  "postal_code": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Opcional; puede omitirse o enviarse `null`."
                  },
                  "notes": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Opcional; puede omitirse o enviarse `null`."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Cliente creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Client"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); crear requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/clients/trash": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listClientTrash",
        "x-tool": {
          "name": "list_client_trash",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "clients"
        ],
        "summary": "Listar clientes en papelera",
        "description": "Clientes borrados (soft-delete), ordenados por fecha de borrado más reciente.",
        "responses": {
          "200": {
            "description": "Lista de clientes en papelera (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Client"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/clients/{client_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "client_id",
          "in": "path",
          "required": true,
          "description": "UUID del cliente.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getClient",
        "x-tool": {
          "name": "get_client",
          "domain": "crm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "clients"
        ],
        "summary": "Detalle de cliente",
        "description": "Devuelve el cliente si pertenece a la organización y el usuario es miembro.",
        "responses": {
          "200": {
            "description": "Cliente.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Client"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Cliente u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateClient",
        "x-tool": {
          "name": "update_client",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "clients"
        ],
        "summary": "Actualizar cliente",
        "description": "Actualización parcial; todos los campos son opcionales. Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "client_type": {
                    "type": "string",
                    "enum": [
                      "legal",
                      "individual"
                    ]
                  },
                  "logo_url": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "maxLength": 512,
                    "description": "URL del logotipo del cliente. Debe empezar por `http://`, `https://` o `/`. `null` o cadena vacía borra el logo."
                  },
                  "email": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra el email."
                  },
                  "phone": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra el teléfono."
                  },
                  "company": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra la empresa."
                  },
                  "tax_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra el NIF/CIF."
                  },
                  "address": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra la dirección."
                  },
                  "city": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra la ciudad."
                  },
                  "country": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra el país."
                  },
                  "postal_code": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra el código postal."
                  },
                  "notes": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra las notas."
                  },
                  "is_active": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Cliente actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Client"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); actualizar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Cliente u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteClient",
        "x-tool": {
          "name": "delete_client",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "clients"
        ],
        "summary": "Borrar cliente (soft-delete)",
        "description": "Mueve el cliente a la papelera. Requiere admin+.",
        "responses": {
          "204": {
            "description": "Cliente movido a papelera; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); eliminar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Cliente u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/clients/{client_id}/restore": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "client_id",
          "in": "path",
          "required": true,
          "description": "UUID del cliente en papelera.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "restoreClient",
        "x-tool": {
          "name": "restore_client",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "clients"
        ],
        "summary": "Restaurar cliente desde papelera",
        "description": "Saca el cliente de la papelera. Requiere admin+.",
        "responses": {
          "200": {
            "description": "Cliente restaurado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Client"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); restaurar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Cliente u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/clients/{client_id}/purge": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "client_id",
          "in": "path",
          "required": true,
          "description": "UUID del cliente a eliminar permanentemente.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "purgeClient",
        "x-tool": {
          "name": "purge_client",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "clients"
        ],
        "summary": "Eliminar cliente permanentemente (purge)",
        "description": "Hard delete. Solo para clientes ya en papelera. Requiere admin+.",
        "responses": {
          "204": {
            "description": "Cliente eliminado permanentemente; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `admin` o `owner` (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Cliente u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/clients/{client_id}/timeline": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "client_id",
          "in": "path",
          "required": true,
          "description": "UUID del cliente.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listClientTimeline",
        "tags": [
          "clients"
        ],
        "summary": "Timeline unificado del cliente",
        "description": "Todo lo que ha pasado con el cliente, en orden cronológico inverso: facturas, presupuestos, gastos imputados a sus proyectos, oportunidades, anotaciones del CRM sobre ellas, proyectos, contratos, hitos de contrato, peticiones de soporte y reuniones agendadas con él. Requiere admin+ (el mismo gate que ver la ficha del cliente).\n\n**Cada fuente se consulta solo si su app está disponible para la organización.** Una organización sin CRM no recibe un 403 ni filas de CRM: recibe el resto de su timeline. El gate de cada fuente es el de su propio módulo (finanzas, CRM, contratos y mesa de soporte por plan + interruptor de la organización; los presupuestos y las reuniones solo por el interruptor, porque ni `quotes` ni `meetings` han llevado nunca muro de pago; los proyectos por nada, porque son el core gratuito).\n\n**Y cada fuente comprueba además el ROL mínimo de su propio módulo.** Ver la ficha del cliente es admin+, que hoy está por encima de todos esos mínimos, así que hoy el filtro no descarta nada; existe para que el día que ese umbral baje, esta pantalla no se convierta en una puerta trasera a las facturas de quien no puede ver `/finance`. Una fuente que el rol no alcanza no devuelve filas y tampoco devuelve un 403: el timeline sigue respondiendo con lo demás.\n\n**Paginación exacta sobre una ventana acotada.** No es un reparto del `limit` entre fuentes: se traen `offset + limit` entradas de cada fuente y se corta la unión ordenada, así que el `offset` es EXACTO. El precio es la profundidad: `offset + limit` no puede pasar de **600**, y pedir más allá es un 422 `timeline_window_exceeded` en vez de una página a medias. No hay `X-Total-Count`: la carga incremental avanza mientras la página venga llena.",
        "parameters": [
          {
            "name": "types",
            "in": "query",
            "required": false,
            "description": "Tipos a incluir. Vacío (por defecto) = todos los que la organización tenga disponibles.",
            "schema": {
              "type": "array",
              "default": [],
              "items": {
                "type": "string",
                "enum": [
                  "invoice",
                  "quote",
                  "expense",
                  "deal",
                  "crm_note",
                  "project",
                  "contract",
                  "contract_milestone",
                  "request",
                  "meeting"
                ]
              }
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Máximo de entradas a devolver (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de entradas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Entradas del timeline, de la más reciente a la más antigua.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ClientTimelineEntry"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `admin` o `owner` (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Cliente u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`offset + limit` supera la ventana de 600 entradas (`timeline_window_exceeded`), o un `types` fuera del enum.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/clients/{client_id}/contacts": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "client_id",
          "in": "path",
          "required": true,
          "description": "UUID del cliente.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listClientContacts",
        "tags": [
          "clients"
        ],
        "summary": "Listar personas de contacto",
        "description": "Contactos del cliente, con el principal primero y el resto por nombre. Requiere **admin+**.",
        "parameters": [
          {
            "name": "is_active",
            "in": "query",
            "required": false,
            "description": "Filtra por estado activo (dado de baja = `false`).",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de contactos (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ClientContact"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (se requiere admin+).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización o cliente inexistente (o usuario no miembro).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createClientContact",
        "tags": [
          "clients"
        ],
        "summary": "Crear persona de contacto",
        "description": "Da de alta a una persona del cliente. El correo, si viene, es ÚNICO en toda la organización: es lo que identifica a quien escribe, y repetido haría imposible saber de qué cliente es un mensaje entrante (409 `contact_email_taken`). Marcar el contacto como principal apaga al anterior. Requiere **admin+**.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ClientContactCreateIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Contacto creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientContact"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (se requiere admin+).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización o cliente inexistente.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ese correo ya está en otro contacto de la organización (`contact_email_taken`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Datos inválidos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/clients/{client_id}/contacts/{contact_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "client_id",
          "in": "path",
          "required": true,
          "description": "UUID del cliente.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "contact_id",
          "in": "path",
          "required": true,
          "description": "UUID del contacto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getClientContact",
        "tags": [
          "clients"
        ],
        "summary": "Obtener persona de contacto",
        "description": "El contacto tiene que pertenecer a ESE cliente de ESA organización; si no, 404 (indistinguible de inexistente). Requiere **admin+**.",
        "responses": {
          "200": {
            "description": "Contacto.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientContact"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (se requiere admin+).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Contacto, cliente u organización inexistentes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateClientContact",
        "tags": [
          "clients"
        ],
        "summary": "Actualizar persona de contacto",
        "description": "Actualización parcial. Para dar de baja a quien ya no trabaja en el cliente se usa `is_active: false`, NO el borrado: así deja de poder abrir peticiones y las que abrió siguen apuntando a él. Requiere **admin+**.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ClientContactUpdateIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Contacto actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClientContact"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (se requiere admin+).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Contacto, cliente u organización inexistentes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ese correo ya está en otro contacto de la organización (`contact_email_taken`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Datos inválidos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteClientContact",
        "tags": [
          "clients"
        ],
        "summary": "Borrar persona de contacto",
        "description": "Borrado definitivo de la ficha, pensado para la creada por error. Las peticiones que la tuvieran como solicitante NO se borran: quedan sin contacto pero conservan PARA QUÉ CLIENTE eran. Requiere **admin+**.",
        "responses": {
          "204": {
            "description": "Contacto borrado."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (se requiere admin+).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Contacto, cliente u organización inexistentes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/suppliers": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listSuppliers",
        "x-tool": {
          "name": "list_suppliers",
          "domain": "finance",
          "profile": "core",
          "sensitive": true
        },
        "tags": [
          "suppliers"
        ],
        "summary": "Listar proveedores",
        "description": "Proveedores de la organización con estadísticas agregadas (`invoice_count`, `total_spend`). Requiere ser miembro. Filtrable por nombre (`name`).",
        "parameters": [
          {
            "name": "name",
            "in": "query",
            "required": false,
            "description": "Búsqueda por nombre (case-insensitive, parcial).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de proveedores con stats (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de proveedores que cumplen los filtros (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/SupplierWithStats"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createSupplier",
        "x-tool": {
          "name": "create_supplier",
          "domain": "finance",
          "profile": "core",
          "sensitive": true
        },
        "tags": [
          "suppliers"
        ],
        "summary": "Crear proveedor",
        "description": "Crea un proveedor en la organización. `name` y `tax_id` son obligatorios; el resto (email, teléfono, dirección, `logo_url`…) son opcionales. Requiere admin+. Útil para \"convertir en proveedor\" a partir de datos de una factura recibida (prefill con `supplier_name` / `supplier_tax_id`).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "tax_id"
                ],
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "tax_id": {
                    "type": "string",
                    "minLength": 1,
                    "description": "NIF/CIF del proveedor; obligatorio y no vacío."
                  },
                  "email": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Email de contacto; opcional."
                  },
                  "phone": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Teléfono; opcional."
                  },
                  "address": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Dirección postal; opcional."
                  },
                  "logo_url": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "URL del logo del proveedor; opcional."
                  },
                  "notes": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Notas libres; opcional."
                  },
                  "global_supplier_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Entrada del catálogo global desde la que autocompletar: el servidor copia los campos vacíos del payload desde el catálogo y guarda el vínculo. Opcional."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Proveedor creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Supplier"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); crear requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/suppliers/cleanup": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "cleanupSuppliers",
        "tags": [
          "suppliers"
        ],
        "summary": "Purgar proveedores fantasma",
        "description": "Elimina permanentemente los proveedores que no tienen ningún gasto vinculado (ni por `supplier_id` FK ni por coincidencia de nombre en `vendor`) y tampoco tienen facturas de proveedor (AP invoices). Devuelve cuántos se eliminaron y cuántos se conservaron. Requiere admin+.",
        "responses": {
          "200": {
            "description": "Resultado de la purga.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SupplierCleanupResult"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente; admin+ requerido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/suppliers/{supplier_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "supplier_id",
          "in": "path",
          "required": true,
          "description": "UUID del proveedor.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getSupplier",
        "x-tool": {
          "name": "get_supplier",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "suppliers"
        ],
        "summary": "Detalle de proveedor",
        "description": "Devuelve el proveedor si pertenece a la organización y el usuario es miembro.",
        "responses": {
          "200": {
            "description": "Proveedor.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Supplier"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proveedor u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateSupplier",
        "x-tool": {
          "name": "update_supplier",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "suppliers"
        ],
        "summary": "Actualizar proveedor",
        "description": "Actualización parcial: los campos ausentes no se tocan. `name` y `tax_id` son obligatorios en la ficha, así que pueden omitirse pero no enviarse vacíos ni a `null` (422). `logo_url` puede actualizarse o borrarse (`null`). Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "tax_id": {
                    "type": "string",
                    "minLength": 1,
                    "description": "NIF/CIF; obligatorio en la ficha. Omitir para no tocarlo; `null` o vacío devuelve 422."
                  },
                  "email": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra el email."
                  },
                  "phone": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra el teléfono."
                  },
                  "address": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra la dirección."
                  },
                  "logo_url": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra el logo."
                  },
                  "notes": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra las notas."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Proveedor actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Supplier"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); actualizar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proveedor u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteSupplier",
        "x-tool": {
          "name": "delete_supplier",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "suppliers"
        ],
        "summary": "Eliminar proveedor",
        "description": "Elimina el proveedor permanentemente. Las facturas de proveedor asociadas mantienen `supplier_id = null` (SET NULL). Requiere admin+.",
        "responses": {
          "204": {
            "description": "Proveedor eliminado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); eliminar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proveedor u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/crm/pipelines": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listPipelines",
        "x-tool": {
          "name": "list_pipelines",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crm"
        ],
        "summary": "Listar pipelines",
        "description": "Pipelines de la organización con sus etapas; requiere ser miembro. Si la organización no tiene ninguno, se siembra automáticamente un pipeline \"Ventas\" por defecto (create-on-read idempotente) para que el tablero nunca esté vacío.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de pipelines (al menos el sembrado por defecto).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Pipeline"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createPipeline",
        "x-tool": {
          "name": "create_pipeline",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crm"
        ],
        "summary": "Crear pipeline",
        "description": "Crea un pipeline (sin etapas). Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "is_default": {
                    "type": "boolean",
                    "default": false,
                    "description": "Si es `true`, deja de ser default cualquier otro pipeline."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Pipeline creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Pipeline"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); crear requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/crm/pipelines/{pipeline_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "pipeline_id",
          "in": "path",
          "required": true,
          "description": "UUID del pipeline.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getPipeline",
        "tags": [
          "crm"
        ],
        "summary": "Detalle de pipeline",
        "description": "Devuelve el pipeline con sus etapas si pertenece a la organización.",
        "responses": {
          "200": {
            "description": "Pipeline.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Pipeline"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Pipeline u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updatePipeline",
        "tags": [
          "crm"
        ],
        "summary": "Actualizar pipeline",
        "description": "Actualización parcial. Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "is_default": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Pipeline actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Pipeline"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); actualizar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Pipeline u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deletePipeline",
        "tags": [
          "crm"
        ],
        "summary": "Eliminar pipeline",
        "description": "Elimina el pipeline (arrastra sus etapas y deals). Requiere admin+.",
        "responses": {
          "204": {
            "description": "Pipeline eliminado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); eliminar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Pipeline u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/crm/pipelines/{pipeline_id}/stages": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "pipeline_id",
          "in": "path",
          "required": true,
          "description": "UUID del pipeline.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listStages",
        "x-tool": {
          "name": "list_stages",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crm"
        ],
        "summary": "Listar etapas de un pipeline",
        "description": "Etapas del pipeline ordenadas por posición. Requiere ser miembro.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de etapas.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Stage"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Pipeline u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createStage",
        "x-tool": {
          "name": "create_stage",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crm"
        ],
        "summary": "Crear etapa",
        "description": "Añade una etapa al pipeline. Si se omite `position`, se coloca al final. Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "position": {
                    "type": "integer",
                    "description": "Posición 0-based; si se omite, al final del tablero."
                  },
                  "probability": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 100,
                    "default": 0
                  },
                  "is_won": {
                    "type": "boolean",
                    "default": false
                  },
                  "is_lost": {
                    "type": "boolean",
                    "default": false
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Etapa creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Stage"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); crear requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Pipeline u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/crm/stages/{stage_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "stage_id",
          "in": "path",
          "required": true,
          "description": "UUID de la etapa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "updateStage",
        "tags": [
          "crm"
        ],
        "summary": "Actualizar etapa",
        "description": "Actualización parcial (nombre, posición, probabilidad, won/lost). Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "position": {
                    "type": "integer"
                  },
                  "probability": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 100
                  },
                  "is_won": {
                    "type": "boolean"
                  },
                  "is_lost": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Etapa actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Stage"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); actualizar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Etapa u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteStage",
        "tags": [
          "crm"
        ],
        "summary": "Eliminar etapa",
        "description": "Elimina la etapa del pipeline. Requiere admin+.",
        "responses": {
          "204": {
            "description": "Etapa eliminada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); eliminar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Etapa u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/crm/deals": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listDeals",
        "x-tool": {
          "name": "list_deals",
          "domain": "crm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "crm"
        ],
        "summary": "Listar deals",
        "description": "Deals de la organización; requiere ser miembro. Filtrable por `pipeline_id`, `stage_id`, `status`, `owner_id`, `client_id` y texto (`search`, contra `title`, case-insensitive). Paginable con `limit`/`offset` (sin parámetros: máximo 200 filas, como antes); `X-Total-Count` trae el total sin paginar.",
        "parameters": [
          {
            "name": "pipeline_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "stage_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "open",
                "won",
                "lost"
              ]
            }
          },
          {
            "name": "owner_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "client_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "description": "Busca en `title` (case-insensitive).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de deals (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de filas que cumplen los filtros activos (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Deal"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros de filtro inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createDeal",
        "x-tool": {
          "name": "create_deal",
          "domain": "crm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "crm"
        ],
        "summary": "Crear deal",
        "description": "Crea un deal (status inicial `open`). Si se omite `pipeline_id` usa el pipeline por defecto; si se omite `stage_id` usa la primera etapa. La probabilidad, si no se indica, hereda la de la etapa. Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "title"
                ],
                "properties": {
                  "title": {
                    "type": "string"
                  },
                  "pipeline_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid"
                  },
                  "stage_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid"
                  },
                  "client_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid"
                  },
                  "value": {
                    "type": "number",
                    "default": 0
                  },
                  "currency": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "minLength": 3,
                    "maxLength": 3,
                    "description": "Divisa ISO 4217 de la oportunidad. Omitida o `null` = la divisa BASE de la organización, que resuelve el servidor. Antes el default era el literal `EUR` declarado aquí, y este schema no puede saber de qué organización viene la petición: un deal de la filial estadounidense entraba en el pipeline —y en los agregados de BI— etiquetado en euros.",
                    "examples": [
                      "USD"
                    ]
                  },
                  "probability": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "minimum": 0,
                    "maximum": 100
                  },
                  "expected_close_date": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date"
                  },
                  "owner_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Deal creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Deal"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); crear requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido o referencias inválidas (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/crm/deals/{deal_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "deal_id",
          "in": "path",
          "required": true,
          "description": "UUID del deal.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getDeal",
        "x-tool": {
          "name": "get_deal",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crm"
        ],
        "summary": "Detalle de deal",
        "description": "Devuelve el deal si pertenece a la organización.",
        "responses": {
          "200": {
            "description": "Deal.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Deal"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Deal u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateDeal",
        "x-tool": {
          "name": "update_deal",
          "domain": "crm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "crm"
        ],
        "summary": "Actualizar deal",
        "description": "Actualización parcial (no mueve de etapa; usa el endpoint de stage). Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "title": {
                    "type": "string"
                  },
                  "client_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid"
                  },
                  "value": {
                    "type": "number"
                  },
                  "currency": {
                    "type": "string"
                  },
                  "probability": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "minimum": 0,
                    "maximum": 100
                  },
                  "expected_close_date": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date"
                  },
                  "owner_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Deal actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Deal"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); actualizar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Deal u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido o referencias inválidas (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteDeal",
        "x-tool": {
          "name": "delete_deal",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crm"
        ],
        "summary": "Eliminar deal",
        "description": "Elimina el deal de la organización. Requiere admin+.",
        "responses": {
          "204": {
            "description": "Deal eliminado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); eliminar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Deal u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/crm/deals/{deal_id}/stage": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "deal_id",
          "in": "path",
          "required": true,
          "description": "UUID del deal.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "moveDeal",
        "x-tool": {
          "name": "move_deal",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crm"
        ],
        "summary": "Mover deal de etapa (kanban)",
        "description": "Mueve el deal a otra etapa del mismo pipeline y fija su probabilidad a la de la etapa destino. Mover a una etapa `is_won`/`is_lost` ajusta también el `status`. Operación del tablero: requiere member+. `422` si la etapa no pertenece al pipeline del deal.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "stage_id"
                ],
                "properties": {
                  "stage_id": {
                    "type": "string",
                    "format": "uuid"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Deal tras el movimiento.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Deal"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); mover requiere member+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Deal u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "La etapa no pertenece al pipeline del deal (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/crm/deals/{deal_id}/close": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "deal_id",
          "in": "path",
          "required": true,
          "description": "UUID del deal.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "closeDeal",
        "x-tool": {
          "name": "close_deal",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crm"
        ],
        "summary": "Cerrar deal (ganado/perdido)",
        "description": "Fija el `status` a `won`/`lost` y mueve el deal a la etapa `is_won`/`is_lost` del pipeline si existe (con su probabilidad). `lost_reason` solo se guarda al perder. Requiere member+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "result"
                ],
                "properties": {
                  "result": {
                    "type": "string",
                    "enum": [
                      "won",
                      "lost"
                    ]
                  },
                  "lost_reason": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Motivo de pérdida; se ignora si `result` es `won`."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Deal tras el cierre.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Deal"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); cerrar requiere member+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Deal u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/crm/deals/{deal_id}/convert-to-project": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "deal_id",
          "in": "path",
          "required": true,
          "description": "UUID del deal.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "convertDealToProject",
        "x-tool": {
          "name": "convert_deal_to_project",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crm"
        ],
        "summary": "Convertir deal en proyecto (handoff comercial→entrega)",
        "description": "Crea un `Project` a partir del deal (hereda el título como nombre y arrastra el `client_id` del deal, seteando el \"spine\" cliente↔proyecto), marca el deal con `converted_project_id` y devuelve el proyecto. Cierra el salto de CRM a entrega sin re-teclear. **Idempotente**: si el deal ya se había convertido (y el proyecto sigue existiendo), devuelve ESE proyecto sin crear uno nuevo. Requiere admin+ (gestión de CRM y creación de proyectos). El cuerpo se ignora.",
        "responses": {
          "201": {
            "description": "Proyecto creado a partir del deal, o el ya vinculado si el deal se había convertido antes (idempotente).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Project"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); convertir requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Deal u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`client_not_found`: el deal apunta a un cliente que ya no está activo en la organización. Borrar un cliente es un SOFT-delete, así que el `client_id` del deal sobrevive al borrado y al convertir no se puede heredar. Se arregla restaurando el cliente de la papelera o quitando el `client_id` del deal antes de convertir.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/crm/leads": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listLeads",
        "x-tool": {
          "name": "list_leads",
          "domain": "crm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "crm"
        ],
        "summary": "Listar leads",
        "description": "Leads de la organización; requiere ser miembro. Filtrable por `status` y texto (`search`, contra nombre/email/empresa, case-insensitive). Paginable con `limit`/`offset` (sin parámetros: máximo 200 filas, como antes); `X-Total-Count` trae el total sin paginar.",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "new",
                "contacted",
                "qualified",
                "converted",
                "lost"
              ]
            }
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "description": "Busca en `name`, `email` o `company` (case-insensitive).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de leads (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de filas que cumplen los filtros activos (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Lead"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros de filtro inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createLead",
        "x-tool": {
          "name": "create_lead",
          "domain": "crm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "crm"
        ],
        "summary": "Crear lead",
        "description": "Crea un lead (status inicial `new`). Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "email": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "phone": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "company": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "source": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "new",
                      "contacted",
                      "qualified",
                      "converted",
                      "lost"
                    ],
                    "default": "new"
                  },
                  "notes": {
                    "type": [
                      "string",
                      "null"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Lead creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Lead"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); crear requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/crm/leads/{lead_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "lead_id",
          "in": "path",
          "required": true,
          "description": "UUID del lead.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getLead",
        "tags": [
          "crm"
        ],
        "summary": "Detalle de lead",
        "description": "Devuelve el lead si pertenece a la organización.",
        "responses": {
          "200": {
            "description": "Lead.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Lead"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Lead u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateLead",
        "tags": [
          "crm"
        ],
        "summary": "Actualizar lead",
        "description": "Actualización parcial. Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "email": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "phone": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "company": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "source": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "new",
                      "contacted",
                      "qualified",
                      "converted",
                      "lost"
                    ]
                  },
                  "notes": {
                    "type": [
                      "string",
                      "null"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Lead actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Lead"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); actualizar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Lead u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteLead",
        "x-tool": {
          "name": "delete_lead",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crm"
        ],
        "summary": "Eliminar lead",
        "description": "Elimina el lead de la organización. Requiere admin+.",
        "responses": {
          "204": {
            "description": "Lead eliminado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); eliminar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Lead u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/crm/leads/{lead_id}/convert": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "lead_id",
          "in": "path",
          "required": true,
          "description": "UUID del lead.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "convertLead",
        "x-tool": {
          "name": "convert_lead",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crm"
        ],
        "summary": "Convertir lead en cliente",
        "description": "Crea un cliente (`clients`) a partir del lead (nombre/email/teléfono/empresa), marca el lead como `converted` con su `converted_client_id` y devuelve el cliente creado. Requiere admin+ (gestión de clientes).",
        "responses": {
          "201": {
            "description": "Cliente creado a partir del lead.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Client"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); convertir requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Lead u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/crm/activities": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listCrmActivities",
        "x-tool": {
          "name": "list_crm_activities",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crm"
        ],
        "summary": "Listar actividades",
        "description": "Actividades de la organización (más recientes primero); requiere ser miembro. Filtrable por `deal_id` y/o `lead_id`. Paginable con `limit`/`offset` (sin parámetros: máximo 200 filas, comportamiento retrocompatible); `X-Total-Count` trae el total sin paginar.",
        "parameters": [
          {
            "name": "deal_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "lead_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de actividades (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de filas que cumplen los filtros activos (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CrmActivity"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros de paginación inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createCrmActivity",
        "x-tool": {
          "name": "create_crm_activity",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crm"
        ],
        "summary": "Crear actividad",
        "description": "Registra una actividad contra un deal o un lead (al menos uno es obligatorio). Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "type",
                  "body"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "call",
                      "email",
                      "meeting",
                      "note",
                      "task"
                    ]
                  },
                  "body": {
                    "type": "string"
                  },
                  "deal_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid"
                  },
                  "lead_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Actividad creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CrmActivity"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); crear requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Falta deal_id/lead_id o referencias inválidas (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/crm/activities/{activity_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "activity_id",
          "in": "path",
          "required": true,
          "description": "UUID de la actividad.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "deleteCrmActivity",
        "tags": [
          "crm"
        ],
        "summary": "Eliminar actividad",
        "description": "Elimina la actividad de la organización. Requiere admin+.",
        "responses": {
          "204": {
            "description": "Actividad eliminada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); eliminar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Actividad u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/crm/analytics/pipeline": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "pipeline_id",
          "in": "query",
          "required": false,
          "description": "Pipeline a analizar; si se omite, el pipeline por defecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "crmPipelineMetrics",
        "x-tool": {
          "name": "crm_pipeline_metrics",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crm"
        ],
        "summary": "Métricas por etapa del pipeline",
        "description": "Recuento e importe (bruto y ponderado por probabilidad) de deals abiertos por etapa, incluyendo etapas vacías. Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Métricas por etapa (una fila por etapa del pipeline).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PipelineMetric"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Pipeline u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/crm/analytics/funnel": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "pipeline_id",
          "in": "query",
          "required": false,
          "description": "Pipeline a analizar; si se omite, el pipeline por defecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "crmFunnel",
        "x-tool": {
          "name": "crm_funnel",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crm"
        ],
        "summary": "Embudo del pipeline (drop-off)",
        "description": "Etapas ordenadas con recuento de deals abiertos y drop-off respecto a la etapa anterior. Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Etapas del embudo en orden.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/FunnelStage"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Pipeline u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/crm/analytics/summary": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "crmSummary",
        "x-tool": {
          "name": "crm_summary",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crm"
        ],
        "summary": "Resumen del CRM",
        "description": "Pipeline abierto (recuento e importe), negocio ganado, tasa de conversión y leads por estado. Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Resumen del CRM.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CrmSummary"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/crossorg/orgs/search": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "searchOrgsToLink",
        "x-tool": {
          "name": "search_orgs_to_link",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Buscar organizaciones para enlazar",
        "description": "Otras organizaciones por nombre o slug, excluyendo la propia y las ya enlazadas (con enlace pendiente o aceptado). Requiere ser miembro.",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Búsqueda por nombre o slug (case-insensitive).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Máximo de resultados (1..200).",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Organizaciones candidatas (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/OrgSummary"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/crossorg/link-requests": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listOrgLinks",
        "x-tool": {
          "name": "list_org_links",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Listar enlaces entre organizaciones",
        "description": "Enlaces de la organización según dirección: `incoming` (recibidos, esta org es el target) u `outgoing` (enviados, esta org es el requester). member+.",
        "parameters": [
          {
            "name": "direction",
            "in": "query",
            "required": false,
            "description": "`incoming` | `outgoing` (por defecto `incoming`).",
            "schema": {
              "type": "string",
              "enum": [
                "incoming",
                "outgoing"
              ],
              "default": "incoming"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de enlaces (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/OrgLink"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "requestOrgLink",
        "x-tool": {
          "name": "request_org_link",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Solicitar enlace con otra organización",
        "description": "Crea una solicitud de enlace `pending` desde esta organización (requester) hacia `target_org_id`. Requiere admin+. `409` si ya existe un enlace vivo entre ambas; `422` si el target es la propia organización.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "target_org_id"
                ],
                "properties": {
                  "target_org_id": {
                    "type": "string",
                    "format": "uuid",
                    "description": "Organización destino de la solicitud."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Solicitud creada (`pending`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OrgLink"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); solicitar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización (propia o destino) inexistente o no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ya existe un enlace vivo entre ambas organizaciones (`conflict`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido o target = organización propia (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/crossorg/link-requests/{link_id}/accept": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "link_id",
          "in": "path",
          "required": true,
          "description": "UUID del enlace.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "acceptOrgLink",
        "x-tool": {
          "name": "accept_org_link",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Aceptar una solicitud de enlace",
        "description": "Acepta un enlace `pending`. Solo la organización TARGET puede aceptar (admin+). `403` si esta org no es el target; `409` si no está pendiente.",
        "responses": {
          "200": {
            "description": "Enlace aceptado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OrgLink"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Esta organización no es el target del enlace (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Enlace inexistente o ajeno a esta organización (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "El enlace no está pendiente (`conflict`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/crossorg/link-requests/{link_id}/reject": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "link_id",
          "in": "path",
          "required": true,
          "description": "UUID del enlace.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "rejectOrgLink",
        "tags": [
          "crossorg"
        ],
        "summary": "Rechazar una solicitud de enlace",
        "description": "Rechaza un enlace `pending`. Solo la organización TARGET puede rechazar (admin+). `403` si esta org no es el target; `409` si no está pendiente.",
        "responses": {
          "200": {
            "description": "Enlace rechazado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OrgLink"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Esta organización no es el target del enlace (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Enlace inexistente o ajeno a esta organización (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "El enlace no está pendiente (`conflict`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/crossorg/link-requests/{link_id}/revoke": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "link_id",
          "in": "path",
          "required": true,
          "description": "UUID del enlace.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "revokeOrgLink",
        "x-tool": {
          "name": "revoke_org_link",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Revocar un enlace propio",
        "description": "Revoca un enlace `pending` o `accepted`. Solo la organización REQUESTER puede revocar (admin+). Revocar oculta al instante los proyectos que se habían compartido a su amparo. `403` si esta org no es el requester.",
        "responses": {
          "200": {
            "description": "Enlace revocado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OrgLink"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Esta organización no es el requester del enlace (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Enlace inexistente o ajeno a esta organización (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "El enlace no es revocable en su estado actual (`conflict`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/shared": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listSharedProjects",
        "x-tool": {
          "name": "list_shared_projects",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Proyectos compartidos conmigo",
        "description": "Proyectos de OTRAS organizaciones compartidos con esta a través de un enlace aceptado. Enriquecidos con la org dueña y el permiso. member+.",
        "responses": {
          "200": {
            "description": "Proyectos compartidos (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/SharedProject"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/shares": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto (debe pertenecer a esta organización).",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listProjectShares",
        "x-tool": {
          "name": "list_project_shares",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Listar comparticiones de un proyecto",
        "description": "Organizaciones con las que se ha compartido el proyecto (lado dueño). member+; el proyecto debe pertenecer a esta organización.",
        "responses": {
          "200": {
            "description": "Comparticiones (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ProjectShare"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto/organización inexistente o no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "shareProject",
        "x-tool": {
          "name": "share_project",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Compartir un proyecto con otra organización",
        "description": "Comparte el proyecto (de esta organización) con `shared_with_org_id`. Requiere admin+, que el proyecto pertenezca a esta org, y un `OrgLink` ACEPTADO entre ambas (`422` si no hay enlace aceptado). `409` si ya estaba compartido con esa organización.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "shared_with_org_id"
                ],
                "properties": {
                  "shared_with_org_id": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "permission": {
                    "type": "string",
                    "enum": [
                      "read",
                      "read_comment",
                      "full"
                    ],
                    "default": "read"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Proyecto compartido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProjectShare"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); compartir requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto/organización inexistente o no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "El proyecto ya está compartido con esa organización (`conflict`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Sin enlace aceptado con el destino, o cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/shares/{share_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "share_id",
          "in": "path",
          "required": true,
          "description": "UUID de la compartición.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "unshareProject",
        "x-tool": {
          "name": "unshare_project",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Dejar de compartir un proyecto",
        "description": "Elimina la compartición (lado dueño). Requiere admin+; la compartición debe ser de este proyecto y de esta organización dueña.",
        "responses": {
          "204": {
            "description": "Compartición eliminada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); descompartir requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Compartición/proyecto inexistente o ajeno a esta organización (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/shared-projects/{project_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto ajeno compartido con esta organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getSharedProject",
        "x-tool": {
          "name": "get_shared_project",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Leer un proyecto compartido (solo lectura)",
        "description": "Detalle de solo lectura de un proyecto de OTRA organización compartido con esta (superficie cross-tenant controlada). member+. `404` si el proyecto no está compartido con esta organización, si el enlace no está aceptado, o si es un proyecto propio (esos se leen por el endpoint normal de projects).",
        "responses": {
          "200": {
            "description": "Detalle del proyecto compartido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SharedProjectDetail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No compartido con esta organización, o no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/shared-projects/{project_id}/tasks": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto ajeno compartido con esta organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listSharedProjectTasks",
        "x-tool": {
          "name": "list_shared_project_tasks",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Tareas de un proyecto compartido (solo lectura)",
        "description": "Tareas (solo lectura) de un proyecto compartido con esta organización. Mismo shape que `Task`. member+. `404` si no está compartido / enlace no aceptado / proyecto propio.\n\nPaginable con `limit`/`offset` (retrocompatible: sin parámetros devuelve como mucho 200 tareas, igual que antes).",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Tareas del proyecto compartido (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Task"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No compartido con esta organización, o no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createSharedTask",
        "x-tool": {
          "name": "create_shared_task",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Crear tarea en proyecto compartido (cross-tenant)",
        "description": "Crea una tarea en un proyecto de OTRA organización compartido con esta con permiso `full`. La tarea queda en el tenant host (`organization_id` del host). `assignee_id`, `parent_id` y `sprint_id` son opcionales y se VALIDAN contra el host (el asignado debe ser miembro del host o colaborador externo activo; padre/sprint deben pertenecer al mismo proyecto) → `422` si no. Requiere member+. `403` si el permiso es `read` o el enlace no está aceptado.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TaskCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Tarea creada en el tenant host.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Task"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Permiso insuficiente (`read` share o enlace no aceptado) (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto no compartido con esta organización (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/departments": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listDepartments",
        "tags": [
          "departments"
        ],
        "summary": "Listar departamentos",
        "description": "Lista paginada de departamentos de la organización. Requiere member+.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de departamentos (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de departamentos de la organización (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Department"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createDepartment",
        "tags": [
          "departments"
        ],
        "summary": "Crear departamento",
        "description": "Crea un departamento en la organización. `name` es obligatorio y único por organización (case-insensitive). `acronym` es opcional pero, si se indica, también es único por organización (se guarda en MAYÚSCULAS). Requiere manager+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DepartmentCreateIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Departamento creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Department"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente; manager+ requerido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Nombre (`department_name_taken`) o acrónimo (`acronym_taken`) ya en uso en la organización.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (validación fallida).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/departments/{department_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "department_id",
          "in": "path",
          "required": true,
          "description": "UUID del departamento.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getDepartment",
        "tags": [
          "departments"
        ],
        "summary": "Detalle de departamento",
        "responses": {
          "200": {
            "description": "Departamento.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Department"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Departamento u organización inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateDepartment",
        "tags": [
          "departments"
        ],
        "summary": "Actualizar departamento",
        "description": "Actualización parcial. Si se cambia `name`, sincroniza `employees.department` (string legacy) para los empleados vinculados. Requiere manager+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DepartmentUpdateIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Departamento actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Department"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente; manager+ requerido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Departamento u organización inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Nombre (`department_name_taken`) o acrónimo (`acronym_taken`) ya en uso en la organización.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteDepartment",
        "tags": [
          "departments"
        ],
        "summary": "Eliminar departamento",
        "description": "Elimina el departamento permanentemente. `employees.department_id` → NULL automático; el string `employees.department` se conserva. Requiere manager+.",
        "responses": {
          "204": {
            "description": "Departamento eliminado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente; manager+ requerido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Departamento u organización inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/departments/{department_id}/projects": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "department_id",
          "in": "path",
          "required": true,
          "description": "UUID del departamento.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listDepartmentProjects",
        "tags": [
          "departments"
        ],
        "summary": "Listar proyectos de un departamento",
        "description": "Proyectos (activos) vinculados al departamento. Permiso DERIVADO: `manager` o superior accede siempre; un `member` normal solo si pertenece al departamento. Aditivo — no altera el acceso org-wide de `/projects`.",
        "responses": {
          "200": {
            "description": "Lista de proyectos del departamento (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Project"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "El usuario no pertenece al departamento y su rol no alcanza el acceso total (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Departamento u organización inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/departments/{department_id}/visibility-projects": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "department_id",
          "in": "path",
          "required": true,
          "description": "UUID del departamento.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listDepartmentVisibilityProjects",
        "tags": [
          "departments"
        ],
        "summary": "Listar proyectos a los que el departamento da acceso",
        "description": "Proyectos (activos) restringidos que este departamento puede ver, vía el set M:N `project_visibility_departments`. Es la cara \"departamento\" de la MISMA fuente de verdad que `GET /projects/{project_id}/visibility-departments`. NO confundir con `GET /departments/{department_id}/projects` (proyectos cuyo `department_id` \"dueño\" es este departamento). Cada elemento incluye el `access_level` (`read` | `write`) que la asociación concede. Requiere rol `admin` o `manager`.",
        "responses": {
          "200": {
            "description": "Proyectos a los que el departamento da acceso (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/DepartmentVisibilityProject"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `manager` o superior (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Departamento u organización inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "put": {
        "operationId": "setDepartmentVisibilityProjects",
        "tags": [
          "departments"
        ],
        "summary": "Fijar los proyectos a los que el departamento da acceso",
        "description": "Reemplaza el conjunto COMPLETO de proyectos a los que este departamento da acceso (semántica PUT: el set queda exactamente como los ids enviados; lista vacía = el departamento deja de dar acceso a ningún proyecto). Cada `project_id` debe ser un proyecto activo de la MISMA organización (`422 project_not_found` si alguno no). Requiere rol `admin` o `manager`.\n\nDos formas de enviar el set, mutuamente excluyentes (enviar ambas o ninguna → `422 validation_error`): `projects` (con nivel de acceso por proyecto) o `project_ids` (forma histórica; equivale a `write` para todos).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "projects": {
                    "type": "array",
                    "description": "Proyectos (de la MISMA org) a los que el departamento dará acceso, cada uno con su nivel. Duplicados por `project_id` se ignoran (gana el primero); el orden no es significativo.",
                    "items": {
                      "type": "object",
                      "title": "DepartmentVisibilityProjectIn",
                      "required": [
                        "project_id"
                      ],
                      "properties": {
                        "project_id": {
                          "type": "string",
                          "format": "uuid"
                        },
                        "access_level": {
                          "allOf": [
                            {
                              "$ref": "#/components/schemas/ProjectAccessLevel"
                            }
                          ],
                          "default": "write"
                        }
                      }
                    }
                  },
                  "project_ids": {
                    "type": "array",
                    "description": "Forma histórica (equivale a `projects` con `access_level: write` para todos). UUIDs de los proyectos (de la MISMA org) a los que el departamento dará acceso. Duplicados se ignoran; el orden no es significativo.",
                    "items": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Proyectos a los que el departamento da acceso tras la actualización.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/DepartmentVisibilityProject"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `manager` o superior (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Departamento u organización inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Algún `project_id` no es un proyecto activo de la organización (`project_not_found`), se enviaron `projects` y `project_ids` a la vez o ninguno de los dos, o cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/employees": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listEmployees",
        "x-tool": {
          "name": "list_employees",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "employees"
        ],
        "summary": "Listar empleados",
        "description": "Empleados de la organización; requiere ser miembro. Filtrable por texto (`q`, contra nombre/apellidos/email), por `department` y por `is_active`. Ordenados por apellidos y luego nombre.",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Búsqueda por nombre, apellidos o email (case-insensitive).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "department",
            "in": "query",
            "required": false,
            "description": "Filtra por departamento exacto.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "is_active",
            "in": "query",
            "required": false,
            "description": "Filtra por estado activo.",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de empleados (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de empleados que cumplen los filtros (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Employee"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createEmployee",
        "x-tool": {
          "name": "create_employee",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "employees"
        ],
        "summary": "Crear empleado",
        "description": "Crea un empleado en la organización (`is_active` inicial `true`). Solo `first_name` y `last_name` son obligatorios; `employment_type` por defecto `interno` y `currency` por defecto la divisa base de la organización. `tax_id` (según `tax_id_type`), `bank_iban` (mod-97) y `bank_routing_number` (checksum ABA) se validan si se aportan.\n**El alta NO es solo española.** `social_security_number` y `pagas` se validan con la regla de `tax_country` —la de la ficha si se declara, si no la de la organización—: NUSS de 11-12 dígitos y 12 o 14 pagas en España, SSN de 9 dígitos y 12/24/26/52 pagas en EE. UU., y solo comprobación de forma en un país sin regla conocida. Para una organización española sin `tax_country`, el comportamiento es idéntico al de siempre. Requiere admin+.\n**Restricción de email**: si se aporta `email`, debe pertenecer a un usuario Projekt que sea miembro de esta organización; si no, 422 `user_not_registered`. Con `invite=true`, si el email no tiene cuenta o no es miembro, se crea la invitación pendiente y la ficha employee se devuelve con `invited: true`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "first_name",
                  "last_name"
                ],
                "properties": {
                  "first_name": {
                    "type": "string"
                  },
                  "last_name": {
                    "type": "string"
                  },
                  "invite": {
                    "type": "boolean",
                    "default": false,
                    "description": "Si el email no tiene cuenta Projekt o no es miembro, crear una invitación pendiente en lugar de rechazar (422)."
                  },
                  "email": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "position": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "department": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "employment_type": {
                    "type": "string",
                    "enum": [
                      "interno",
                      "autonomo",
                      "externo",
                      "w2",
                      "1099"
                    ],
                    "description": "Enum CERRADO a propósito: esto es ESCRITURA. Ampliarlo es retrocompatible y mantenerlo cerrado convierte un `w-2` mal escrito en un 422 en vez de en un dato guardado. La RESPUESTA (Employee) sí lo trae abierto — ver la nota de employee.yaml.",
                    "default": "interno"
                  },
                  "base_salary": {
                    "type": [
                      "number",
                      "null"
                    ]
                  },
                  "currency": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "minLength": 3,
                    "maxLength": 3,
                    "description": "Divisa ISO 4217 del salario. Omitida o `null` = la divisa BASE de la organización, que resuelve el servidor. Antes el default era el literal `EUR` declarado aquí: la ficha de un empleado de la filial estadounidense guardaba su sueldo en dólares etiquetado como euros, y de ahí salen nóminas y costes.",
                    "examples": [
                      "USD"
                    ]
                  },
                  "hire_date": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date"
                  },
                  "tax_id_type": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "enum": [
                      "DNI",
                      "NIE",
                      "CIF",
                      "SSN",
                      "EIN",
                      "ITIN",
                      null
                    ],
                    "description": "Decide con qué regla se valida `tax_id`. Autodescriptivo: no está limitado por `tax_country`."
                  },
                  "tax_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Validado contra `tax_id_type` si se aporta (DNI/NIE/CIF con su dígito de control; SSN/EIN/ITIN con los rangos de la SSA/IRS)."
                  },
                  "social_security_number": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Número laboral, validado según `tax_country`: NUSS de 11-12 dígitos en España, SSN de 9 dígitos en EE. UU., solo forma en un país sin regla conocida."
                  },
                  "birth_date": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date"
                  },
                  "nationality": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "phone": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "address": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "city": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "province": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "postal_code": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "country": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "tax_country": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "minLength": 2,
                    "maxLength": 2,
                    "description": "Jurisdicción laboral/fiscal del empleado (ISO 3166-1 alfa-2). Omitida o `null` = la de la organización. Decide la regla de `social_security_number` y de `pagas`; no la de `tax_id_type`, que es autodescriptivo.",
                    "examples": [
                      "US"
                    ]
                  },
                  "bank_iban": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "IBAN validado mod-97 si se aporta."
                  },
                  "bank_routing_number": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Routing number ABA (EE. UU.): 9 dígitos con checksum 3-7-1. Obligatorio junto con `bank_account_number`: enviar uno solo devuelve 422, porque media cuenta no cobra una nómina.",
                    "examples": [
                      "021000021"
                    ]
                  },
                  "bank_account_number": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Número de cuenta no-IBAN (4-34 alfanuméricos). Obligatorio junto con `bank_routing_number`.",
                    "examples": [
                      "000123456789"
                    ]
                  },
                  "gross_annual_salary": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "minimum": 0,
                    "description": "Salario bruto anual con el que se calculan las nóminas (`POST /hr/payroll/runs` solo genera payslip a los empleados que lo tienen puesto). Omitido o `null` = sin configurar. PII.",
                    "examples": [
                      30000
                    ]
                  },
                  "irpf_rate": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "minimum": 0,
                    "maximum": 100,
                    "description": "Tipo de retención IRPF en PORCENTAJE (0–100, no una fracción); fuera de ese rango es 422. Omitido o `null` = sin configurar. PII.",
                    "examples": [
                      15
                    ]
                  },
                  "pagas": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "Pagas anuales; admisibles según `tax_country` (12 o 14 en España; 12/24/26/52 en EE. UU.; 1-53 en un país sin tabla). Omitir el campo aplica el valor normal del país —12 en España, nada fuera de ella—; `null` explícito significa «no se sabe».",
                    "examples": [
                      26
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Empleado creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Employee"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); crear requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/employees/bulk": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "bulkEmployeeAction",
        "x-tool": {
          "name": "bulk_employee_action",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "employees"
        ],
        "summary": "Acción en lote sobre empleados",
        "description": "Aplica `activate`/`deactivate`/`delete` a varios empleados. Devuelve un resultado honesto: `processed` (cambiaron), `skipped` (activate/deactivate ya en el estado destino) y `errors` (id inexistente o de otro tenant). Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "ids",
                  "action"
                ],
                "properties": {
                  "ids": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "type": "string",
                      "format": "uuid"
                    }
                  },
                  "action": {
                    "type": "string",
                    "enum": [
                      "activate",
                      "deactivate",
                      "delete"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Resultado por-elemento de la acción.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BulkActionResult"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/employees/department-stats": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "employeeDepartmentStats",
        "x-tool": {
          "name": "employee_department_stats",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "employees"
        ],
        "summary": "Plantilla por departamento",
        "description": "Recuento de empleados por departamento (headcount), de mayor a menor. El departamento sin asignar se agrupa bajo `null`. Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Agregado por departamento (puede ser vacío).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/EmployeeDepartmentStat"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/employees/{employee_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "employee_id",
          "in": "path",
          "required": true,
          "description": "UUID del empleado.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getEmployee",
        "x-tool": {
          "name": "get_employee",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "employees"
        ],
        "summary": "Detalle de empleado",
        "description": "Devuelve el empleado si pertenece a la organización y el usuario es miembro.",
        "responses": {
          "200": {
            "description": "Empleado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Employee"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Empleado u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateEmployee",
        "x-tool": {
          "name": "update_employee",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "employees"
        ],
        "summary": "Actualizar empleado",
        "description": "Actualización parcial; todos los campos son opcionales. `tax_id`, `bank_iban` y `bank_routing_number` se validan si se aportan.\n`social_security_number` y `pagas` se validan sobre el ESTADO RESULTANTE con la regla de `tax_country`: cambiar de país a un empleado sin corregirle el número o las pagas devuelve 422 en lugar de dejar guardado un dato que ya no encaja con su jurisdicción. Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "first_name": {
                    "type": "string"
                  },
                  "last_name": {
                    "type": "string"
                  },
                  "email": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "position": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "department": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "employment_type": {
                    "type": "string",
                    "enum": [
                      "interno",
                      "autonomo",
                      "externo",
                      "w2",
                      "1099"
                    ],
                    "description": "Enum CERRADO a propósito: esto es ESCRITURA. Ampliarlo es retrocompatible y mantenerlo cerrado convierte un `w-2` mal escrito en un 422 en vez de en un dato guardado. La RESPUESTA (Employee) sí lo trae abierto — ver la nota de employee.yaml."
                  },
                  "base_salary": {
                    "type": [
                      "number",
                      "null"
                    ]
                  },
                  "currency": {
                    "type": "string"
                  },
                  "hire_date": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date"
                  },
                  "tax_id_type": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "enum": [
                      "DNI",
                      "NIE",
                      "CIF",
                      "SSN",
                      "EIN",
                      "ITIN",
                      null
                    ],
                    "description": "Decide con qué regla se valida `tax_id`. Autodescriptivo: no está limitado por `tax_country`."
                  },
                  "tax_id": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "social_security_number": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "birth_date": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date"
                  },
                  "nationality": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "phone": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "address": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "city": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "province": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "postal_code": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "country": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "tax_country": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "minLength": 2,
                    "maxLength": 2,
                    "description": "Jurisdicción laboral/fiscal del empleado (ISO 3166-1 alfa-2). Omitida o `null` = la de la organización. Decide la regla de `social_security_number` y de `pagas`; no la de `tax_id_type`, que es autodescriptivo.",
                    "examples": [
                      "US"
                    ]
                  },
                  "bank_iban": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "bank_routing_number": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Routing number ABA (EE. UU.): 9 dígitos con checksum 3-7-1. Obligatorio junto con `bank_account_number`: enviar uno solo devuelve 422, porque media cuenta no cobra una nómina.",
                    "examples": [
                      "021000021"
                    ]
                  },
                  "bank_account_number": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Número de cuenta no-IBAN (4-34 alfanuméricos). Obligatorio junto con `bank_routing_number`.",
                    "examples": [
                      "000123456789"
                    ]
                  },
                  "gross_annual_salary": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "minimum": 0,
                    "description": "Salario bruto anual. `null` explícito lo deja SIN configurar, y entonces `POST /hr/payroll/runs` deja de generarle payslip. PII.",
                    "examples": [
                      30000
                    ]
                  },
                  "irpf_rate": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "minimum": 0,
                    "maximum": 100,
                    "description": "Tipo de retención IRPF en PORCENTAJE (0–100, no una fracción); fuera de ese rango es 422. `null` lo deja sin configurar. PII.",
                    "examples": [
                      15
                    ]
                  },
                  "pagas": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "Pagas anuales; admisibles según `tax_country` (12 o 14 en España; 12/24/26/52 en EE. UU.; 1-53 en un país sin tabla). Omitir el campo aplica el valor normal del país —12 en España, nada fuera de ella—; `null` explícito significa «no se sabe».",
                    "examples": [
                      26
                    ]
                  },
                  "is_active": {
                    "type": "boolean"
                  },
                  "department_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "UUID del departamento estructurado al que se vincula la ficha. `null` desvincula (el texto de `department` se CONSERVA). Si se envía sin `department`, el nombre del departamento se copia en él para que las dos formas no se contradigan. Devuelve 422 si el departamento no pertenece a esta organización.\nSu hermano `manager_id` estaba declarado y este no, así que el vínculo estructurado —que el API acepta y guarda— era inalcanzable para cualquier SDK."
                  },
                  "manager_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "UUID del empleado manager directo. `null` elimina el manager asignado. Devuelve 422 si el id no existe en la org, si es el propio empleado, o si crearía un ciclo en la jerarquía."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Empleado actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Employee"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); actualizar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Empleado u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteEmployee",
        "x-tool": {
          "name": "delete_employee",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "employees"
        ],
        "summary": "Eliminar empleado",
        "description": "Elimina el empleado de la organización. Requiere admin+.",
        "responses": {
          "204": {
            "description": "Empleado eliminado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); eliminar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Empleado u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/time-entries": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listTimeEntries",
        "x-tool": {
          "name": "list_time_entries",
          "domain": "pm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "time-entries"
        ],
        "summary": "Listar registros de tiempo",
        "description": "Registros de tiempo de la organización (más recientes primero); requiere ser miembro. Filtrable por proyecto (`project_id`), autor (`user_id`) y rango de fechas imputadas (`from`/`to`). Un `member` solo ve SUS propios registros (sin `user_id` se devuelven los suyos; pedir otro autor es `forbidden`); `admin`/`owner` pueden listar cualquier autor u omitir `user_id` para ver toda la organización.\n\nPaginable con `limit`/`offset` (retrocompatible: sin parámetros devuelve como mucho 200 registros, igual que antes). La cabecera `X-Total-Count` trae el total que cumple los filtros, sin paginar.",
        "parameters": [
          {
            "name": "project_id",
            "in": "query",
            "required": false,
            "description": "Filtra por proyecto.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "user_id",
            "in": "query",
            "required": false,
            "description": "Filtra por autor. `member`: solo su propio id (otro autor = `forbidden`). `admin`/`owner`: cualquier autor.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "Fecha imputada desde (inclusive).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "Fecha imputada hasta (inclusive).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de registros de tiempo (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de registros que cumplen los filtros activos (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/TimeEntry"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Un `member` pidió los registros de otro autor (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros de paginación/filtro inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createTimeEntry",
        "x-tool": {
          "name": "create_time_entry",
          "domain": "pm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "time-entries"
        ],
        "summary": "Crear registro de tiempo",
        "description": "Crea un registro de tiempo en la organización. El autor (`user_id`) es siempre el usuario autenticado; cualquier valor aportado se ignora. `minutes` debe ser mayor que 0.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "minutes",
                  "entry_date"
                ],
                "properties": {
                  "minutes": {
                    "type": "integer",
                    "minimum": 1,
                    "description": "Duración imputada en minutos (mayor que 0)."
                  },
                  "entry_date": {
                    "type": "string",
                    "format": "date",
                    "description": "Fecha a la que se imputa el tiempo."
                  },
                  "project_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Opcional; proyecto imputado."
                  },
                  "task_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Opcional; tarea imputada."
                  },
                  "description": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Opcional; puede omitirse o enviarse `null`."
                  },
                  "is_billable": {
                    "type": "boolean",
                    "description": "Opcional; si el tiempo es facturable al cliente. Default `true` si se omite.",
                    "default": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Registro de tiempo creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TimeEntry"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); crear requiere member+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/jornada/asientos": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listJornadaAsientos",
        "x-tool": {
          "name": "list_jornada_asientos",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "jornada"
        ],
        "summary": "Listar asientos del registro de jornada",
        "description": "Asientos del registro de jornada, del más reciente al más antiguo. Filtrable por persona (`user_id`) y rango de instantes (`from`/`to`).\n\nUn `member` solo ve LOS SUYOS: sin `user_id` se devuelven los suyos, y pedir los de otra persona es `forbidden`. `manager` y superiores pueden pedir los de cualquiera u omitir `user_id` para ver la organización entera. Nadie ve nunca los de otra organización.",
        "parameters": [
          {
            "name": "user_id",
            "in": "query",
            "required": false,
            "description": "Filtra por persona. `member`: solo su propio id.",
            "schema": {
              "oneOf": [
                {
                  "type": "string",
                  "format": "uuid"
                },
                {
                  "type": "null"
                }
              ]
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "Instante desde (inclusive), UTC.",
            "schema": {
              "oneOf": [
                {
                  "type": "string",
                  "format": "date-time"
                },
                {
                  "type": "null"
                }
              ]
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "Instante hasta (inclusive), UTC.",
            "schema": {
              "oneOf": [
                {
                  "type": "string",
                  "format": "date-time"
                },
                {
                  "type": "null"
                }
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de asientos.",
            "headers": {
              "X-Total-Count": {
                "description": "Total de asientos que cumplen los filtros, sin paginar.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/JornadaAsiento"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Un `member` pidió los asientos de otra persona (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Filtros inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createJornadaAsiento",
        "x-tool": {
          "name": "create_jornada_asiento",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "jornada"
        ],
        "summary": "Registrar un asiento de jornada",
        "description": "Escribe un asiento nuevo. El servidor pone `registrado_en`, `numero`, `huella` y `huella_anterior`: el cliente no puede aportarlos, porque si pudiera la cadena no probaría nada.\n\nNo existe editar ni borrar. Para rectificar un asiento se crea otro con `corrige_a` y `motivo`, y los dos quedan en el registro.\n\n`motivo` es obligatorio en tres casos: al rectificar, al registrar por otra persona, y al aportar un `ocurrido_en` distinto de ahora.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/JornadaAsientoCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Asiento registrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JornadaAsiento"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Registrar por otra persona sin rol suficiente, o la app Workforce no está en la edición contratada (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente, usuario no miembro, o `corrige_a` no encontrado (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Falta `motivo` donde es obligatorio, `ocurrido_en` en el futuro, o el tipo no encaja con el estado (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/jornada/hoy": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getJornadaHoy",
        "x-tool": {
          "name": "get_jornada_hoy",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "jornada"
        ],
        "summary": "Estado de la jornada de hoy",
        "description": "Lo que necesita la pantalla de fichar para saber qué botón enseñar: si la jornada está abierta, si hay una pausa abierta, cuánto se lleva trabajado y los asientos del día.\n\nSin `user_id` responde por quien llama. Pedir el de otra persona exige `manager` o superior.",
        "parameters": [
          {
            "name": "user_id",
            "in": "query",
            "required": false,
            "schema": {
              "oneOf": [
                {
                  "type": "string",
                  "format": "uuid"
                },
                {
                  "type": "null"
                }
              ]
            }
          },
          {
            "name": "fecha",
            "in": "query",
            "required": false,
            "description": "Día a consultar. Por defecto, hoy en la zona de la organización.",
            "schema": {
              "oneOf": [
                {
                  "type": "string",
                  "format": "date"
                },
                {
                  "type": "null"
                }
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Estado del día.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JornadaDia"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Pidió el día de otra persona sin rol suficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/jornada/verificacion": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "verificarJornada",
        "x-tool": {
          "name": "verificar_jornada",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "jornada"
        ],
        "summary": "Comprobar que el registro no se ha tocado",
        "description": "Recalcula la cadena de huellas de toda la organización y dice si cuadra. Es lo que convierte «inalterable» en algo que se puede enseñar: si alguien editó una fila por detrás, aquí sale cuál.\n\nExige `admin` o superior: es una comprobación sobre el registro de toda la plantilla.",
        "responses": {
          "200": {
            "description": "Resultado de la comprobación.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JornadaVerificacion"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/verifactu/registros": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listVerifactuRegistros",
        "x-tool": {
          "name": "list_verifactu_registros",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "verifactu"
        ],
        "summary": "Listar el registro de facturación",
        "description": "Los registros de facturación de la organización, del más reciente al más antiguo. Exige `admin` o superior: es el libro fiscal, y quien lo enseña a una inspección es quien responde de él.",
        "parameters": [
          {
            "name": "tipo",
            "in": "query",
            "required": false,
            "description": "Filtra por tipo de registro.",
            "schema": {
              "oneOf": [
                {
                  "type": "string",
                  "enum": [
                    "alta",
                    "anulacion"
                  ]
                },
                {
                  "type": "null"
                }
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de registros.",
            "headers": {
              "X-Total-Count": {
                "description": "Total de registros que cumplen los filtros, sin paginar.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/VerifactuRegistro"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente, o la app Finanzas no está en la edición contratada (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Filtros inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/verifactu/facturas/{invoice_id}/cotejo": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "invoice_id",
          "in": "path",
          "required": true,
          "description": "UUID de la factura.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getVerifactuCotejo",
        "x-tool": {
          "name": "get_verifactu_cotejo",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "verifactu"
        ],
        "summary": "El QR de cotejo de una factura",
        "description": "La URL que va dentro del QR de la factura y la leyenda que la acompaña. Se construye desde el REGISTRO de alta, no desde la factura: si la factura se rectificó después, sus importes ya no son los que se registraron.",
        "responses": {
          "200": {
            "description": "La URL de cotejo y su leyenda.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VerifactuCotejo"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente, usuario no miembro, o la factura no tiene registro de facturación (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/verifactu/verificacion": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "verificarVerifactu",
        "x-tool": {
          "name": "verificar_verifactu",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "verifactu"
        ],
        "summary": "Comprobar que el registro no se ha tocado",
        "description": "Recalcula la cadena de huellas del libro entero y dice si cuadra. Si alguien editó una fila por detrás, aquí sale cuál y de qué tipo: huella que no cuadra es fila editada, hueco en la numeración es fila borrada, y enganche que no encaja es fila insertada en medio.",
        "responses": {
          "200": {
            "description": "Resultado de la comprobación.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VerifactuVerificacion"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/time-entries": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto (la ruta lo fija; el tiempo se imputa a él).",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "createProjectTimeEntry",
        "x-tool": {
          "name": "create_time_entry",
          "domain": "pm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "time-entries"
        ],
        "summary": "Crear registro de tiempo en un proyecto",
        "description": "Igual que `createTimeEntry` pero con el proyecto en la RUTA (no en el body). Al llevar `{project_id}` en el path, un PAT scoped a ese proyecto puede imputar tiempo (el endpoint org-level lo rechaza por scope). El autor es siempre el usuario autenticado; `task_id` opcional debe pertenecer a este proyecto (si no, `validation_error`).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "minutes",
                  "entry_date"
                ],
                "properties": {
                  "minutes": {
                    "type": "integer",
                    "minimum": 1,
                    "description": "Duración imputada en minutos (mayor que 0)."
                  },
                  "entry_date": {
                    "type": "string",
                    "format": "date",
                    "description": "Fecha a la que se imputa el tiempo."
                  },
                  "task_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Opcional; tarea de ESTE proyecto."
                  },
                  "description": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Opcional; puede omitirse o enviarse `null`."
                  },
                  "is_billable": {
                    "type": "boolean",
                    "description": "Opcional; si el tiempo es facturable al cliente. Default `true` si se omite.",
                    "default": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Registro de tiempo creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TimeEntry"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente o PAT scoped a otro proyecto (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido o `task_id` fuera del proyecto (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/time-entries/start": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "startTimeEntry",
        "x-tool": {
          "name": "start_timer",
          "domain": "pm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "time-entries"
        ],
        "summary": "Arrancar un timer en vivo",
        "description": "Arranca un timer (`minutes` queda NULL hasta pararlo). El autor (`user_id`) es siempre el usuario autenticado. Un usuario tiene como mucho UN timer en curso por organización: si ya hay uno corriendo, `409 timer_already_running` (pararlo antes de arrancar otro).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TimeEntryStartIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Timer arrancado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TimeEntryRunning"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); arrancar requiere member+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ya hay un timer en curso (`timer_already_running`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido, o `project_id`/`task_id` no pertenecen a la organización (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/time-entries/active": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getActiveTimeEntry",
        "x-tool": {
          "name": "get_active_timer",
          "domain": "pm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "time-entries"
        ],
        "summary": "Timer en curso del usuario actual",
        "description": "Devuelve el timer en curso del usuario autenticado en esta organización, si lo hay. SIEMPRE `200`: `timer=null` (con `is_running=false`) cuando no hay ninguno corriendo — la ausencia de timer activo no es un error.",
        "responses": {
          "200": {
            "description": "Estado del timer actual (corriendo o no).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ActiveTimerOut"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/time-entries/summary": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getTimeEntriesSummary",
        "x-tool": {
          "name": "time_summary",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "time-entries"
        ],
        "summary": "Totales de tiempo imputado (agregados)",
        "description": "Agrega los registros de tiempo COMPLETADOS de la organización (excluye timers en curso) que cumplen los filtros: gran total + desglose opcional por proyecto o autor. Mismo scoping por rol que `listTimeEntries`: un `member` solo agrega SUS propios registros (pedir otro autor es `forbidden`); `admin`/`owner` pueden agregar cualquier autor u omitir `user_id` para ver toda la organización.",
        "parameters": [
          {
            "name": "project_id",
            "in": "query",
            "required": false,
            "description": "Filtra por proyecto.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "user_id",
            "in": "query",
            "required": false,
            "description": "Filtra por autor. `member`: solo su propio id (otro autor = `forbidden`). `admin`/`owner`: cualquier autor.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "Fecha imputada desde (inclusive).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "Fecha imputada hasta (inclusive).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "group_by",
            "in": "query",
            "required": false,
            "description": "Dimensión de desglose de `groups`.",
            "schema": {
              "type": "string",
              "enum": [
                "project",
                "user",
                "none"
              ],
              "default": "none"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Totales agregados.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TimeEntrySummary"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Un `member` pidió el agregado de otro autor (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros inválidos, p.ej. `group_by` fuera de enum (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/time-entries/{entry_id}/stop": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "entry_id",
          "in": "path",
          "required": true,
          "description": "UUID del timer en curso a parar.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "stopTimeEntry",
        "x-tool": {
          "name": "stop_timer",
          "domain": "pm",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "time-entries"
        ],
        "summary": "Parar un timer en vivo",
        "description": "Para el timer y fija `minutes` a la duración transcurrida desde `started_at` (redondeada ARRIBA a minutos completos, mínimo 1). A partir de aquí el registro se comporta como cualquier `TimeEntry` completado (aparece en list/get/summary). Requiere ser el autor del timer o admin+; `404` si `entry_id` no es un timer EN CURSO de esta organización (no existe, es de otra organización, o ya se paró).",
        "responses": {
          "200": {
            "description": "Registro de tiempo completado (timer parado).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TimeEntry"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); requiere autor o admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente, usuario no miembro, o `entry_id` no es un timer en curso de esta organización (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/time-entries/trash": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listTimeEntryTrash",
        "tags": [
          "time-entries"
        ],
        "summary": "Listar imputaciones en papelera",
        "description": "Registros de tiempo borrados y todavía restaurables. Aplica el MISMO gate por rol que el listado normal: un `member` solo ve las suyas; `manager`+ ve las de toda la organización. Se devuelven de la más recientemente borrada a la más antigua, que es el orden en el que alguien busca lo que acaba de tirar. Lo que pasó de los 30 días de retención ya no está: se lo lleva el barrido nocturno.",
        "responses": {
          "200": {
            "description": "Imputaciones en papelera (lista, sin paginar).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/TimeEntry"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "La organización no tiene la funcionalidad `timesheets` (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/time-entries/{entry_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "entry_id",
          "in": "path",
          "required": true,
          "description": "UUID del registro de tiempo.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getTimeEntry",
        "x-tool": {
          "name": "get_time_entry",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "time-entries"
        ],
        "summary": "Detalle de registro de tiempo",
        "description": "Devuelve el registro si pertenece a la organización y el usuario es miembro. Un `member` solo puede leer SUS propios registros: los de otro autor responden `404` opaco (sin filtrar existencia); `admin`/`owner` pueden leer cualquiera.",
        "responses": {
          "200": {
            "description": "Registro de tiempo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TimeEntry"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Registro u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateTimeEntry",
        "x-tool": {
          "name": "update_time_entry",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "time-entries"
        ],
        "summary": "Actualizar registro de tiempo",
        "description": "Actualización parcial; todos los campos son opcionales. Requiere ser el autor del registro o admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "minutes": {
                    "type": "integer",
                    "minimum": 1
                  },
                  "entry_date": {
                    "type": "string",
                    "format": "date"
                  },
                  "project_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "`null` desasocia el proyecto."
                  },
                  "task_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "`null` desasocia la tarea."
                  },
                  "description": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` borra la descripción."
                  },
                  "is_billable": {
                    "type": "boolean",
                    "description": "Si el tiempo es facturable al cliente. Opcional en el PATCH; solo se aplica si viene. No admite `null` (columna NOT NULL): enviar `null` explícito devuelve `validation_error`."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Registro de tiempo actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TimeEntry"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); requiere autor o admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Registro u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteTimeEntry",
        "x-tool": {
          "name": "delete_time_entry",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "time-entries"
        ],
        "summary": "Eliminar registro de tiempo",
        "description": "Elimina el registro de la organización. Requiere ser el autor o admin+.",
        "responses": {
          "204": {
            "description": "Registro eliminado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); requiere autor o admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Registro u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "La hora ya está facturada (`validation_error`): un registro imputado a una factura no se borra, porque el importe emitido dejaría de cuadrar.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/time-entries/{entry_id}/restore": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "entry_id",
          "in": "path",
          "required": true,
          "description": "UUID de la imputación en papelera.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "restoreTimeEntry",
        "tags": [
          "time-entries"
        ],
        "summary": "Restaurar imputación desde papelera",
        "description": "Devuelve la imputación al listado y a los totales. Mismo permiso que borrarla: su autor, o admin+. Un `entry_id` que existe pero NO está en la papelera responde `404` igual que uno inexistente — restaurar lo que ya está vivo no es un caso, es un error de quien llama.",
        "responses": {
          "200": {
            "description": "Imputación restaurada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TimeEntry"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); requiere ser el autor o admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Imputación u organización inexistente, imputación que no está en papelera, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/time-entries/{entry_id}/purge": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "entry_id",
          "in": "path",
          "required": true,
          "description": "UUID de la imputación a eliminar permanentemente.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "purgeTimeEntry",
        "tags": [
          "time-entries"
        ],
        "summary": "Eliminar imputación permanentemente (purge)",
        "description": "Hard delete, sin vuelta. Solo sobre imputaciones que YA están en la papelera, y requiere admin+ — más que borrarla, que puede hacerlo su autor: es la única operación de este módulo que destruye el dato, y por eso es la única que la HIG sigue pidiendo confirmar.",
        "responses": {
          "204": {
            "description": "Imputación eliminada permanentemente; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `admin` u `owner` (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Imputación u organización inexistente, imputación que no está en papelera, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/documents": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listDocuments",
        "x-tool": {
          "name": "list_documents",
          "domain": "docs",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "documents"
        ],
        "summary": "Listar documentos",
        "description": "Documentos de la organización (editados más recientemente primero); requiere ser miembro. Lista PLANA; el cliente reconstruye el árbol por `parent_id`. Filtrable por proyecto (`project_id`), por documento padre (`parent_id`, hijos directos), por carpeta (`folder_id`), por tipo (`kind`) y por texto (`q`, contra el título).\n\nLos documentos archivados en una carpeta RESTRINGIDA a departamentos a los que el usuario no pertenece se excluyen del listado (y de `X-Total-Count`).",
        "parameters": [
          {
            "name": "project_id",
            "in": "query",
            "required": false,
            "description": "Filtra por proyecto.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "parent_id",
            "in": "query",
            "required": false,
            "description": "Filtra por documento padre (devuelve solo sus hijos directos).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "folder_id",
            "in": "query",
            "required": false,
            "description": "Filtra por carpeta. Una carpeta que el usuario no puede ver responde `404`, no una lista vacía (no se confirma que exista).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "kind",
            "in": "query",
            "required": false,
            "description": "Filtra por tipo de documento.",
            "schema": {
              "type": "string",
              "enum": [
                "page",
                "file"
              ]
            }
          },
          {
            "name": "is_template",
            "in": "query",
            "required": false,
            "description": "`true` devuelve SOLO las plantillas de la organización; `false` solo los documentos que no lo son. Omitido = todos (una plantilla es un documento y sigue apareciendo en su carpeta).",
            "schema": {
              "type": [
                "boolean",
                "null"
              ]
            }
          },
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Búsqueda por título (case-insensitive).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de documentos (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de documentos que cumplen los filtros (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Document"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createDocument",
        "x-tool": {
          "name": "create_document",
          "domain": "docs",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "documents"
        ],
        "summary": "Crear documento",
        "description": "Crea un documento de tipo `page` (markdown) en la organización. El autor (`created_by`) es siempre el usuario autenticado. Solo `title` es obligatorio. Para subir un FICHERO, usa `POST /organizations/{org_id}/documents/upload`. Para partir de una plantilla, `POST /organizations/{org_id}/documents/from-template`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "title"
                ],
                "properties": {
                  "title": {
                    "type": "string"
                  },
                  "content": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Markdown libre; opcional (puede omitirse o enviarse `null`)."
                  },
                  "project_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Opcional; proyecto asociado."
                  },
                  "folder_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Opcional; carpeta donde archivarlo. Debe ser una carpeta de la organización que el usuario pueda ver, o se responde `422` `folder_not_found`."
                  },
                  "parent_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Opcional; documento del que cuelga (jerarquía del árbol, que es distinta de la carpeta). Debe existir, ser de la misma organización y ser visible para quien crea, o se responde `404` `not_found`."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Documento creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Document"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); crear requiere member+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/documents/trash": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listDocumentTrash",
        "x-tool": {
          "name": "list_document_trash",
          "domain": "docs",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "documents"
        ],
        "summary": "Listar documentos en papelera",
        "description": "Documentos borrados (soft-delete), más recientes primero. Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Lista de documentos en papelera (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Document"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/documents/upload": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "uploadDocumentFile",
        "tags": [
          "documents"
        ],
        "summary": "Subir un fichero como documento",
        "description": "Crea un documento de tipo `file` con el fichero enviado. Requiere member+.\n\nValidaciones (en este orden): permisos sobre proyecto/carpeta/padre, tamaño máximo **10 MB** (`413` `file_too_large`), extensión dentro de la allow-list y CONTENIDO real coherente con ella (`422` `unsupported_file_type`), y cupo de almacenamiento del plan (`403` `storage_limit_exceeded`).\n\nExtensiones admitidas: `png`, `jpg`, `jpeg`, `webp`, `gif`, `pdf`, `docx`, `xlsx`, `pptx`, `csv`, `txt`, `zip`. El MIME que declare el cliente se ignora: se deduce de la extensión. El nombre se reduce a su basename, así que un `../` no viaja a ninguna parte.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "El fichero."
                  },
                  "title": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Título del documento; por defecto el nombre del fichero."
                  },
                  "project_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Opcional; proyecto asociado."
                  },
                  "folder_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Opcional; carpeta donde archivarlo."
                  },
                  "parent_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Opcional; documento padre en el árbol."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Documento de tipo `file` creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Document"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`) o límite de almacenamiento del plan alcanzado (`storage_limit_exceeded`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "El fichero supera los 10 MB (`file_too_large`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Extensión no admitida o contenido que no se corresponde con ella (`unsupported_file_type`), o carpeta/proyecto/padre inválidos (`folder_not_found`, `validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/documents/from-template": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "createDocumentFromTemplate",
        "tags": [
          "documents"
        ],
        "summary": "Crear documento desde una plantilla",
        "description": "Crea un documento COPIANDO el contenido de una plantilla. La copia es independiente desde el primer instante: editarla no toca la plantilla y retirar la plantilla no la afecta.\n\nEl destino lo decide quien crea (`folder_id`, `project_id`, `parent_id`), no la plantilla: se crea donde estás. El documento nuevo NUNCA nace como plantilla (`is_template: false`) ni hereda su publicación — el enlace público de la plantilla no se copia.\n\nPermiso: el mismo que crear un documento (member+). Marcar plantillas es admin+; usarlas, no.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DocumentFromTemplateIn"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Documento creado a partir de la plantilla.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Document"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`template_id` no es una plantilla visible de esta organización, o la carpeta / proyecto / padre de destino no valen (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/documents/{document_id}/template": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "document_id",
          "in": "path",
          "required": true,
          "description": "UUID del documento.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "markDocumentAsTemplate",
        "tags": [
          "documents"
        ],
        "summary": "Marcar documento como plantilla",
        "description": "Convierte el documento en una plantilla de la organización: pasa a ofrecerse en «nuevo documento desde plantilla». Requiere **admin+** — una plantilla es mobiliario compartido por toda la organización, así que la decide el rol en la organización y no la autoría del documento concreto.\n\nIdempotente: marcar una plantilla ya marcada devuelve `200` y no cambia nada. Solo se pueden marcar documentos `kind: page`; un fichero subido responde `422` (`invalid_template_kind`), porque «copiar la plantilla» significa copiar `content` y un fichero no lo tiene.",
        "responses": {
          "200": {
            "description": "Documento, ya marcado como plantilla.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Document"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); hace falta admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Documento u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "El documento no puede ser plantilla (`invalid_template_kind`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "retireDocumentTemplate",
        "tags": [
          "documents"
        ],
        "summary": "Retirar la plantilla",
        "description": "Deja de ofrecer el documento como plantilla. NO lo borra: sigue siendo un documento normal en su carpeta, y los documentos ya creados a partir de él no se ven afectados (son copias independientes). Requiere admin+. Idempotente.",
        "responses": {
          "204": {
            "description": "Retirada (o ya no era plantilla)."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); hace falta admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Documento u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/documents/{document_id}/publication": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "document_id",
          "in": "path",
          "required": true,
          "description": "UUID del documento.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getDocumentPublication",
        "tags": [
          "documents"
        ],
        "summary": "Ver el estado de publicación",
        "description": "Dice si el documento está publicado y, si lo está, DEVUELVE EL ENLACE. Es la diferencia deliberada con el enlace de un presupuesto, que solo se enseña una vez: el de un documento se puede volver a consultar tantas veces como haga falta, porque se deriva de `APP_SECRET` en vez de almacenarse. Requiere admin+.",
        "responses": {
          "200": {
            "description": "Estado de publicación (con el enlace si está publicado).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DocumentPublication"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); hace falta admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Documento u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "publishDocument",
        "tags": [
          "documents"
        ],
        "summary": "Publicar el documento",
        "description": "Genera (o reactiva) el enlace público de solo lectura y lo devuelve. Idempotente: publicar dos veces devuelve `200` y EL MISMO enlace. Publicar lo que se despublicó también devuelve el mismo — a propósito, para que un enlace ya repartido sobreviva a una despublicación temporal.\n\nCon `rotate: true` el enlace anterior muere para siempre y nace otro: es la salida para un enlace filtrado.\n\nRequiere admin+ y solo aplica a documentos `kind: page`; publicar un fichero subido responde `422` (`invalid_publish_kind`).",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DocumentPublishIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Documento publicado; incluye `token` y `public_url`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DocumentPublication"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); hace falta admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Documento u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "El documento no se puede publicar (`invalid_publish_kind`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "unpublishDocument",
        "tags": [
          "documents"
        ],
        "summary": "Despublicar el documento",
        "description": "Apaga el enlace: a partir de aquí `GET /public/documents/{token}` responde `404`. Idempotente. El enlace NO se destruye —volver a publicar devuelve el mismo—; para matarlo de verdad hay que publicar con `rotate: true`. Requiere admin+.",
        "responses": {
          "204": {
            "description": "Despublicado (o ya no estaba publicado)."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); hace falta admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Documento u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/documents/{token}": {
      "parameters": [
        {
          "name": "token",
          "in": "path",
          "required": true,
          "description": "Token público obtenido al publicar el documento.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getPublicDocument",
        "tags": [
          "documents"
        ],
        "summary": "Ver documento publicado (sin login)",
        "description": "Vista de solo lectura de un documento publicado. NO requiere autenticación: la credencial es el token de la URL. Rate-limited por IP.\n\nDevuelve `404` —el mismo que un token inexistente— si el documento se despublicó, se rotó el enlace, se movió a la papelera o se borró. Al visitante anónimo no se le confirma nunca que un token haya existido.\n\nSolo salen `title`, `content` y `updated_at`: ni ids internos, ni autor, ni nombre de la organización, ni comentarios, ni historial de versiones.",
        "security": [],
        "responses": {
          "200": {
            "description": "Documento publicado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicDocument"
                }
              }
            }
          },
          "404": {
            "description": "Token inválido, despublicado, rotado o documento borrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demasiadas peticiones desde esta IP (`too_many_requests`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/documents/{document_id}/download": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "document_id",
          "in": "path",
          "required": true,
          "description": "UUID del documento de tipo `file`.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "downloadDocumentFile",
        "tags": [
          "documents"
        ],
        "summary": "Descargar el fichero de un documento",
        "description": "Devuelve los bytes del documento con `Content-Disposition: attachment`. Requiere ser miembro y poder ver el documento: un documento de otra organización, en la papelera, en una carpeta restringida ajena o de tipo `page` responde `404` — nunca `403`, que confirmaría que existe.",
        "responses": {
          "200": {
            "description": "El fichero.",
            "content": {
              "application/octet-stream": {}
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Documento u organización inexistente, sin fichero, o usuario sin acceso (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/documents/{document_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "document_id",
          "in": "path",
          "required": true,
          "description": "UUID del documento.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getDocument",
        "x-tool": {
          "name": "get_document",
          "domain": "docs",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "documents"
        ],
        "summary": "Detalle de documento",
        "description": "Devuelve el documento si pertenece a la organización y el usuario es miembro.",
        "responses": {
          "200": {
            "description": "Documento.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Document"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Documento u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateDocument",
        "x-tool": {
          "name": "update_document",
          "domain": "docs",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "documents"
        ],
        "summary": "Actualizar documento",
        "description": "Actualización parcial; todos los campos son opcionales. Requiere ser el autor del documento o admin+. `parent_id`/`position` mueven el documento dentro del árbol; un `parent_id` que crearía un ciclo (el documento como su propio ancestro) se rechaza con `422`. Si cambian `title` o `content` se archiva el estado previo en el historial de versiones.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "title": {
                    "type": "string"
                  },
                  "content": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "`null` vacía el contenido del documento."
                  },
                  "project_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "`null` desasocia el proyecto."
                  },
                  "folder_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Mueve el documento a esa carpeta; `null` lo saca de la carpeta. Una carpeta de otra organización o que el usuario no ve se rechaza con `422` `folder_not_found`."
                  },
                  "parent_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Documento padre en el árbol; `null` lo mueve a la raíz. Debe ser otro documento de la misma organización y no puede crear un ciclo."
                  },
                  "position": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Orden entre hermanos (menor primero)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Documento actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Document"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); requiere autor o admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Documento u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido o `parent_id` ajeno a la org / que crearía un ciclo (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteDocument",
        "x-tool": {
          "name": "delete_document",
          "domain": "docs",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "documents"
        ],
        "summary": "Borrar documento (soft-delete)",
        "description": "Mueve el documento a la papelera (soft-delete). Requiere ser el autor o admin+.",
        "responses": {
          "204": {
            "description": "Documento movido a papelera; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); requiere autor o admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Documento u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/documents/{document_id}/restore": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "document_id",
          "in": "path",
          "required": true,
          "description": "UUID del documento en papelera.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "restoreDocument",
        "x-tool": {
          "name": "restore_document",
          "domain": "docs",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "documents"
        ],
        "summary": "Restaurar documento desde papelera",
        "description": "Saca el documento de la papelera. Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Documento restaurado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Document"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente para restaurar este documento (`forbidden`) o el plan de la organización no incluye el módulo (`feature_not_in_plan`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Documento u organización inexistente, o usuario no miembro (`not_found`). También cuando el documento vive en una carpeta que el usuario no puede ver: ahí el 404 va ANTES que el gate de rol, para que un 403 no delate que ese documento existe.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/documents/{document_id}/purge": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "document_id",
          "in": "path",
          "required": true,
          "description": "UUID del documento a eliminar permanentemente.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "purgeDocument",
        "x-tool": {
          "name": "purge_document",
          "domain": "docs",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "documents"
        ],
        "summary": "Eliminar documento permanentemente (purge)",
        "description": "Hard delete. Solo para documentos ya en papelera. Requiere admin+.",
        "responses": {
          "204": {
            "description": "Documento eliminado permanentemente; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `admin` o `owner` (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Documento u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/documents/{document_id}/versions": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "document_id",
          "in": "path",
          "required": true,
          "description": "UUID del documento.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listDocumentVersions",
        "x-tool": {
          "name": "list_document_versions",
          "domain": "docs",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "documents"
        ],
        "summary": "Listar versiones",
        "description": "Historial de versiones del documento (más recientes primero). Cada ítem OMITE el contenido completo; expone `content_length` y `content_preview`. Requiere ser miembro.\n\nPaginable con `limit`/`offset` (retrocompatible: sin parámetros devuelve como mucho 200 versiones). La cabecera `X-Total-Count` trae el total de versiones del documento, sin paginar.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de versiones (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de versiones del documento (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/DocumentVersion"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Documento u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/documents/{document_id}/versions/{version_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "document_id",
          "in": "path",
          "required": true,
          "description": "UUID del documento.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "version_id",
          "in": "path",
          "required": true,
          "description": "UUID de la versión.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getDocumentVersion",
        "x-tool": {
          "name": "get_document_version",
          "domain": "docs",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "documents"
        ],
        "summary": "Detalle de versión",
        "description": "Devuelve una versión concreta del historial con el CONTENIDO markdown completo. Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Versión con contenido completo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DocumentVersionDetail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Versión, documento u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/documents/{document_id}/versions/{version_id}/restore": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "document_id",
          "in": "path",
          "required": true,
          "description": "UUID del documento.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "version_id",
          "in": "path",
          "required": true,
          "description": "UUID de la versión.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "restoreDocumentVersion",
        "x-tool": {
          "name": "restore_document_version",
          "domain": "docs",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "documents"
        ],
        "summary": "Restaurar versión",
        "description": "Archiva el estado ACTUAL del documento como una nueva versión y luego fija su `title`/`content` a los de la versión indicada. Requiere ser el autor del documento o admin+.",
        "responses": {
          "200": {
            "description": "Documento tras restaurar (estado de la versión aplicado).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Document"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); requiere autor o admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Versión, documento u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/documents/{document_id}/comments": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "document_id",
          "in": "path",
          "required": true,
          "description": "UUID del documento.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listDocumentComments",
        "x-tool": {
          "name": "list_document_comments",
          "domain": "docs",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "document-comments"
        ],
        "summary": "Listar comentarios",
        "description": "Comentarios del documento (más recientes primero). Lista plana; el cliente reconstruye los hilos por `parent_id`. Requiere ser miembro.\n\nPaginable con `limit`/`offset` (retrocompatible: sin parámetros devuelve como mucho 200 comentarios). La cabecera `X-Total-Count` trae el total de comentarios del documento, sin paginar. Ojo: al paginar, un hilo puede quedar partido entre páginas — el cliente debe tolerar `parent_id` que apunte a un comentario fuera de la página.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de comentarios (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de comentarios del documento (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/DocumentComment"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Documento u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createDocumentComment",
        "x-tool": {
          "name": "create_document_comment",
          "domain": "docs",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "document-comments"
        ],
        "summary": "Crear comentario",
        "description": "Crea un comentario en el documento (autor = usuario actual). Requiere ser miembro. `parent_id` opcional para responder a otro comentario del MISMO documento.\n\n`anchor` opcional ancla el comentario a un FRAGMENTO del documento (texto citado + vecindad + id de bloque). Sin él, el comentario es sobre el documento entero. Solo tiene sentido en comentarios de primer nivel: una respuesta hereda el ancla de su hilo, así que enviar `anchor` junto a `parent_id` es `422`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "body"
                ],
                "properties": {
                  "body": {
                    "type": "string",
                    "description": "Cuerpo del comentario (texto libre, no vacío)."
                  },
                  "parent_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Comentario padre (del mismo documento) al que se responde; opcional."
                  },
                  "anchor": {
                    "oneOf": [
                      {
                        "$ref": "#/components/schemas/DocumentCommentAnchor"
                      },
                      {
                        "type": "null"
                      }
                    ],
                    "description": "Fragmento comentado. Omitirlo (o `null`) crea un comentario sobre el documento entero."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Comentario creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DocumentComment"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Documento u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido, `parent_id` ajeno al documento, o `anchor` en una respuesta (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/documents/{document_id}/comments/{comment_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "document_id",
          "in": "path",
          "required": true,
          "description": "UUID del documento.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "comment_id",
          "in": "path",
          "required": true,
          "description": "UUID del comentario.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "updateDocumentComment",
        "x-tool": {
          "name": "update_document_comment",
          "domain": "docs",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "document-comments"
        ],
        "summary": "Editar comentario",
        "description": "Edita el cuerpo del comentario y marca `is_edited=true`. Solo el autor del comentario o un admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "body"
                ],
                "properties": {
                  "body": {
                    "type": "string",
                    "description": "Nuevo cuerpo del comentario (texto libre, no vacío)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Comentario actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DocumentComment"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es el autor ni admin+ (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Comentario, documento u organización inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteDocumentComment",
        "x-tool": {
          "name": "delete_document_comment",
          "domain": "docs",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "document-comments"
        ],
        "summary": "Eliminar comentario",
        "description": "Elimina el comentario (y sus respuestas en cascada). Solo el autor del comentario o un admin+.",
        "responses": {
          "204": {
            "description": "Comentario eliminado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es el autor ni admin+ (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Comentario, documento u organización inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/dashboard/stats": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "from",
          "in": "query",
          "required": false,
          "description": "Inicio del rango de finanzas (inclusive), fecha ISO 8601. Sin valor = all-time. No afecta a `tasks_trend` (siempre las últimas 8 semanas).",
          "schema": {
            "type": "string",
            "format": "date"
          }
        },
        {
          "name": "to",
          "in": "query",
          "required": false,
          "description": "Fin del rango de finanzas (inclusive), fecha ISO 8601.",
          "schema": {
            "type": "string",
            "format": "date"
          }
        }
      ],
      "get": {
        "operationId": "dashboardStats",
        "x-tool": {
          "name": "get_dashboard_stats",
          "domain": "search",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "dashboard"
        ],
        "summary": "Métricas del panel de inicio",
        "description": "Agregados del panel de inicio de la organización: tareas por estado, proyectos por estado, prioridad de tareas abiertas, carga por miembro, resumen financiero del rango y tendencia semanal de tareas. Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Métricas agregadas del panel.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DashboardStats"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros de rango inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/dashboard/activity": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "limit",
          "in": "query",
          "required": false,
          "description": "Número máximo de eventos a devolver (1–100).",
          "schema": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100,
            "default": 20
          }
        }
      ],
      "get": {
        "operationId": "dashboardActivity",
        "tags": [
          "dashboard"
        ],
        "summary": "Feed de actividad reciente",
        "description": "Eventos recientes de auditoría de la organización (más nuevos primero), enriquecidos con el nombre del actor y un resumen. Devuelve un array vacío si no hay actividad. Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Lista de eventos recientes.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/DashboardActivityItem"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetro `limit` inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/api-keys": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listApiKeys",
        "x-tool": {
          "name": "list_api_keys",
          "domain": "admin",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "api-keys"
        ],
        "summary": "Listar API keys",
        "description": "API keys de la organización, enmascaradas (incluye las revocadas). NUNCA devuelve el token. owner/admin ve todas; un `member` solo las que creó él. Filtro opcional por proyecto vía `project_id`.",
        "parameters": [
          {
            "name": "project_id",
            "in": "query",
            "required": false,
            "description": "Filtra las keys por proyecto (UUID). Omitir para devolver todas las keys visibles (org-wide y de cualquier proyecto).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de API keys (enmascaradas; puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ApiKey"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`organization_suspended` / `account_suspended`. LISTAR NO TIENE PUERTA DE ROL: un `member` recibe 200 y solo ve las suyas (el filtro lo decide el service, no un rechazo). Decía «requiere owner/admin» y era FALSO — corregido el 31/08/2026, cuando el dueño reportó «api keys no van» y ese 403 se leyó como un problema de permisos que no existe.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createApiKey",
        "tags": [
          "api-keys"
        ],
        "summary": "Crear API key",
        "description": "Genera un PAT `pjk_live_…` cripto-seguro y devuelve el token en claro UNA sola vez (`ApiKeyCreated.token`). El servidor solo persiste su hash SHA-256 y un prefijo; el token no se puede recuperar después.\n\nSi se omite `project_id` la key es org-wide (requiere owner/admin). Si se envía, la key queda acotada a ese proyecto —cualquier miembro puede acuñarla— y el proyecto debe pertenecer a la organización (si no, 404).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Nombre legible de la key (1-120 chars)."
                  },
                  "project_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Proyecto al que acotar la key. Omitir/`null` = org-wide (owner/admin); un UUID = key de proyecto (cualquier miembro)."
                  },
                  "expires_at": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date-time",
                    "description": "Caducidad opcional de la key (RFC3339). Omitir/`null` = la key no caduca nunca. Una vez pasada esa fecha el token deja de autenticar (401)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "API key creada. Incluye el token en claro `token` (mostrado una única vez).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiKeyCreated"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "DOS motivos distintos, y el primero es el que se ve en un navegador: `step_up_required` — esta ruta exige confirmar identidad (PJKT-2111) y esa cookie dura 10 minutos, así que una sesión normal la recibe y el cliente tiene que abrir el modal de step-up; y `forbidden`, cuando un `member` intenta crear una key org-wide (solo owner/admin). El primero no estaba documentado hasta el 31/08/2026: quien leía esto solo podía concluir que le faltaba rol.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización o proyecto inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/api-keys/{key_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "key_id",
          "in": "path",
          "required": true,
          "description": "UUID de la API key.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "revokeApiKey",
        "tags": [
          "api-keys"
        ],
        "summary": "Revocar API key",
        "description": "Revoca la key (fija `revoked_at`); la fila se conserva para auditoría y sigue apareciendo en el listado. Idempotente. owner/admin revoca cualquiera; un `member` solo las que creó él.",
        "responses": {
          "204": {
            "description": "API key revocada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`step_up_required` (misma puerta de confirmación de identidad que el alta, y corre ANTES que el rol) o `forbidden`, cuando un `member` intenta revocar una key que no creó él.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Key u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/store/apps": {
      "get": {
        "operationId": "listStoreApps",
        "tags": [
          "store"
        ],
        "summary": "Catálogo público de apps",
        "description": "Todas las apps ACTIVAS del catálogo, con su modelo (`free`/`freemium`/ `paid`/`addon`), sus precios y el reparto gratis/premium de sus capacidades.\n\n**Se sirve sin sesión**: es el escaparate. Quien todavía no tiene cuenta tiene que poder ver qué hay y cuánto cuesta antes de registrarse — y la página de precios se pinta con esto mismo. No lleva ningún dato de organización: para eso está el catálogo con estado por organización.",
        "security": [],
        "responses": {
          "200": {
            "description": "Catálogo de apps activas, en su orden de presentación.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/StoreApp"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/store/apps": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listOrganizationStoreApps",
        "tags": [
          "store"
        ],
        "summary": "Catálogo de apps con el estado de la organización",
        "description": "El MISMO catálogo público más, por cada app, lo que esta organización tiene: `instalada`, `instalada_en`, su `suscripcion` (`null` si nunca la probó ni la pagó), si `puede_instalar` ahora mismo y las `dependencias` vivas que impedirían desinstalarla.\n\nLo puede leer CUALQUIER miembro: saber qué apps tiene la organización no es una decisión, es contexto. Instalar y desinstalar sí exigen owner/admin.",
        "responses": {
          "200": {
            "description": "Catálogo con el estado de la organización.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/StoreAppForOrganization"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/store/apps/{app_key}/install": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "app_key",
          "in": "path",
          "required": true,
          "description": "Clave de la app del catálogo (p. ej. `finance`).",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "installStoreApp",
        "tags": [
          "store"
        ],
        "summary": "Instalar una app en la organización",
        "description": "Añade la app al escritorio de la organización. **Instalar es gratis siempre**: da acceso al tramo `gratis` de sus capacidades; las `premium` siguen pidiendo suscripción.\n\nEs IDEMPOTENTE y lo dice en el código de estado: **201** cuando la instalación se ha creado con esta llamada, **200** cuando ya estaba instalada. Reinstalar algo que se desinstaló devuelve los datos tal cual estaban (desinstalar nunca los borró). Requiere owner/admin.",
        "responses": {
          "200": {
            "description": "La app ya estaba instalada; no ha cambiado nada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StoreAppForOrganization"
                }
              }
            }
          },
          "201": {
            "description": "App instalada por esta llamada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StoreAppForOrganization"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); requiere owner/admin.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro, o app fuera del catálogo activo (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`app_requires_parent`: es un `addon` y su app anfitriona (`addon_de`) no está instalada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "uninstallStoreApp",
        "tags": [
          "store"
        ],
        "summary": "Desinstalar una app de la organización",
        "description": "Quita la app del escritorio. **No borra NADA**: los datos se quedan y reinstalar los devuelve; lo que desaparece es la app de la navegación, y el API deja de aceptar sus capacidades.\n\nDevuelve el estado resultante de la app (200) en vez de un 204 vacío, para que quien la desinstala vea en la misma respuesta que ya no está instalada y que sus dependencias quedaron a cero — la misma forma que devuelve instalar. Es idempotente: desinstalar algo que no estaba instalado también responde 200. Requiere owner/admin.",
        "responses": {
          "200": {
            "description": "Estado de la app tras desinstalarla.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StoreAppForOrganization"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); requiere owner/admin.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro, o app fuera del catálogo activo (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`app_has_dependencies`: hay vínculos vivos con otras apps. El cuerpo lista cuáles en el mensaje; el detalle por etiqueta viaja en `dependencias` del catálogo por organización.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/store/apps/{app_key}/trial": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "app_key",
          "in": "path",
          "required": true,
          "description": "Clave de la app del catálogo (p. ej. `finance`).",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "startStoreAppTrial",
        "tags": [
          "store"
        ],
        "summary": "Empezar la prueba gratuita de una app",
        "description": "Arranca el periodo de prueba del tramo premium de la app, de tantos días como diga `trial_dias` del catálogo (14 por defecto). Devuelve la suscripción resultante, en estado `trial` y con `trial_hasta` puesto.\n\n**No pide tarjeta y no necesita que el servidor tenga Stripe configurado**: «trial sin tarjeta» es una regla del modelo de negocio, no un atajo de implementación. Por eso este endpoint no responde 503 aunque los pagos estén apagados.\n\nEs de **una vez por organización y app**, y la memoria de que ya se usó no se borra al cancelar: la marca es `trial_hasta`, que sobrevive a la baja. Requiere owner/admin y que la app esté INSTALADA (instalar es gratis).",
        "responses": {
          "201": {
            "description": "Prueba iniciada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StoreAppSubscription"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); requiere owner/admin.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro, app fuera del catálogo activo (`not_found`), o la compra de apps está apagada en este servidor (`store_purchases_disabled`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "`trial_already_used`: esta organización ya gastó la prueba de esta app (el front debe ofrecer «Suscribirse»). `app_already_subscribed`: ya la está pagando (el front debe ofrecer «Gestionar»).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`app_not_installed`: la app no está instalada en la organización. `app_trial_not_available`: la app no ofrece prueba (`trial_dias` es 0, que es lo que llevan las apps `free`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/store/apps/{app_key}/subscribe": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "app_key",
          "in": "path",
          "required": true,
          "description": "Clave de la app del catálogo (p. ej. `finance`).",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "subscribeStoreApp",
        "tags": [
          "store"
        ],
        "summary": "Suscribirse al tramo premium de una app",
        "description": "Empieza a pagar el tramo premium de la app. La respuesta trae UNA de dos cosas y cuál no lo elige el cliente:\n\n· Si la organización ya tiene una suscripción de Stripe (porque compró otra app antes), la app se añade como un ITEM más —con el prorrateo que calcula Stripe por lo que queda de periodo— y la respuesta trae la `suscripcion` ya activa, sin pasar por ninguna pasarela.\n\n· Si es su primera compra, hace falta una tarjeta: la respuesta trae `checkout_url` y el front redirige. La suscripción NO se crea aquí — la crea el webhook cuando el pago cuaja.\n\nEs idempotente en lo que importa: pedirlo sobre algo que ya está pagado devuelve la suscripción existente y NO crea un segundo cobro. Requiere owner/admin, la app instalada y datos fiscales de la organización (razón social, NIF/CIF y domicilio) cuando hay que crear el cliente de Stripe.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/StoreSubscribeRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Suscripción hecha (`suscripcion`) o Checkout pendiente (`checkout_url`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StoreSubscribeResult"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); requiere owner/admin.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro, app fuera del catálogo activo (`not_found`), o la compra de apps está apagada en este servidor (`store_purchases_disabled`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`app_not_installed`: la app no está instalada. `app_not_purchasable`: la app no tiene precio publicado para esa periodicidad. `fiscal_data_required`: faltan los datos fiscales de la organización, que hacen falta para poder facturar.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "`billing_not_configured`: el servidor no tiene Stripe configurado, así que no se puede cobrar nada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "unsubscribeStoreApp",
        "tags": [
          "store"
        ],
        "summary": "Cancelar la suscripción a una app",
        "description": "Cancela **al fin de periodo**. La suscripción pasa a `cancelada` y `cancelada_en` queda sellado, pero **sigue concediendo el tramo premium hasta `renueva_en`**: ese periodo ya está cobrado y no se devuelve, así que tampoco se corta. Cuando llega la fecha, la app degrada a su tramo gratis — nunca se borran datos.\n\nEn Stripe se traduce en quitar el item de la suscripción sin prorrateo, o en `cancel_at_period_end` sobre la suscripción entera cuando era el último item (Stripe no admite suscripciones sin items). Si la app estaba en TRIAL, no hay nada que pedirle a Stripe y la baja funciona aunque los pagos no estén configurados.\n\n**No lo gatea `STORE_PURCHASES_ENABLED`**: apagar las ventas no puede dejar encerrado a quien ya compró (cancelar en dos clics es una regla del documento de economía). Requiere owner/admin.",
        "responses": {
          "200": {
            "description": "Suscripción cancelada; sigue viva hasta `renueva_en`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StoreAppSubscription"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); requiere owner/admin.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro, app fuera del catálogo activo (`not_found`), o la organización no tiene una suscripción viva a esa app (`subscription_not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Reservado; esta operación no valida cuerpo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "`billing_not_configured`: había un item de Stripe que quitar y el servidor no tiene Stripe configurado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/store/subscriptions": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listOrganizationStoreSubscriptions",
        "tags": [
          "store"
        ],
        "summary": "Suscripciones de la organización («Mis apps»)",
        "description": "Lo que la organización paga o está probando, una entrada por app: estado, periodicidad, importe, próxima renovación y fin del trial. Es lo que pinta la página «Mis apps» (gasto mensual, próximas renovaciones, trials a punto de caducar).\n\nIncluye las **muertas** (`viva: false`): un trial caducado o una suscripción cancelada y ya vencida siguen apareciendo, porque esa pantalla tiene que poder decir «tu prueba de Finanzas terminó el martes» — quitarlas escondería justo lo que hay que reconvertir. Lo que concede ahora mismo lo dice `viva`, no la presencia de la entrada.\n\nLo lee CUALQUIER miembro, igual que el catálogo con estado: saber qué paga la organización es contexto, no una decisión. Comprar y cancelar sí exigen owner/admin.",
        "responses": {
          "200": {
            "description": "Suscripciones de la organización, ordenadas por `app_key`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/StoreAppSubscription"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/vault": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listVaultItems",
        "tags": [
          "vault"
        ],
        "summary": "Listar credenciales del vault",
        "description": "Credenciales del vault de la organización, SIN el secreto (ni cifrado ni en claro). Cualquier miembro puede listar, pero solo ve lo que le toca: los `shared_with=admins` aparecen solo para owner/admin y los `shared_with=usuarios` solo para quien la creó, los owner/admin y las personas de `shared_user_ids`. El filtro se aplica en la consulta, así que una credencial que no te corresponde no existe para ti en este listado. Para leer un secreto hay que usar el endpoint de revelado, que audita. Paginable con `limit`/`offset` (retrocompatible: sin parámetros devuelve como mucho 200).",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de credenciales (sin secretos; puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/VaultItem"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createVaultItem",
        "tags": [
          "vault"
        ],
        "summary": "Crear credencial en el vault",
        "description": "Crea una credencial compartida. El `secret` se cifra en reposo y NO vuelve en la respuesta (`VaultItem` no lo incluye): para leerlo, endpoint de revelado. Requiere rol owner/admin. Con `shared_with=usuarios`, las personas de `shared_user_ids` tienen que ser miembros vivos de ESTA organización: si alguna no lo es, 422 y no se crea nada.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/VaultItemCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Credencial creada (sin el secreto).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VaultItem"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); requiere owner/admin.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`validation_error`: cuerpo inválido. O `shared_user_not_member`: alguno de los `shared_user_ids` no es miembro vivo de esta organización — y entonces no se guarda NADA de la petición, ni siquiera los campos que sí valían.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/vault/{item_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "item_id",
          "in": "path",
          "required": true,
          "description": "UUID de la credencial del vault.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "updateVaultItem",
        "tags": [
          "vault"
        ],
        "summary": "Editar credencial del vault",
        "description": "Edición parcial (owner/admin). Si llega `secret` se RE-CIFRA y sustituye al anterior. En `username`/`url`/`notes` un `null` explícito borra el valor; omitir un campo lo conserva. `shared_user_ids` REEMPLAZA la lista entera (mandar `[]` deja la credencial sin destinatarios y el acceso se pierde en la siguiente petición); sus ids tienen que ser miembros vivos de la organización o la edición se rechaza con 422. La respuesta nunca incluye el secreto.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/VaultItemUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Credencial actualizada (sin el secreto).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VaultItem"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); requiere owner/admin.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Credencial u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`validation_error`: cuerpo inválido. O `shared_user_not_member`: alguno de los `shared_user_ids` no es miembro vivo de esta organización — y entonces no se guarda NADA de la petición, ni siquiera los campos que sí valían.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteVaultItem",
        "tags": [
          "vault"
        ],
        "summary": "Borrar credencial del vault",
        "description": "Borra la credencial definitivamente (owner/admin). Queda rastro en el audit log (`vault_item.deleted`), pero el secreto cifrado se elimina.",
        "responses": {
          "204": {
            "description": "Credencial borrada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); requiere owner/admin.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Credencial u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/vault/{item_id}/reveal": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "item_id",
          "in": "path",
          "required": true,
          "description": "UUID de la credencial del vault.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "revealVaultSecret",
        "tags": [
          "vault"
        ],
        "summary": "Revelar el secreto de una credencial",
        "description": "Devuelve el secreto DESCIFRADO de la credencial. Cada revelado escribe una entrada de auditoría (`vault_item.revealed`) con quién y qué. Items `shared_with=org`: cualquier miembro; `shared_with=admins`: solo owner/admin; `shared_with=usuarios`: quien la creó, los owner/admin y las personas de `shared_user_ids`. Al resto, 403 (dentro de la organización la EXISTENCIA de la credencial no es secreta; el secreto sí).",
        "responses": {
          "200": {
            "description": "Secreto descifrado (revelado auditado).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VaultSecretOut"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: la credencial no se comparte con este usuario — `shared_with=admins` sin ser owner/admin, o `shared_with=usuarios` sin estar en `shared_user_ids` (ni ser su creador ni owner/admin).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Credencial u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/notifications/stream": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización activa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "streamNotifications",
        "tags": [
          "notifications"
        ],
        "summary": "Stream de notificaciones en tiempo real (SSE)",
        "description": "Server-Sent Events stream that delivers real-time notification updates to the authenticated user in this organisation. member+.\n\nThe connection is long-lived; the client should keep it open and reconnect on network errors. Use the native browser `EventSource` API — openapi-fetch does not consume SSE streams.\n\n**Event shapes emitted on the stream:**\n\n`retry: 30000` — reconnection delay in ms, sent alongside the first frame.\nIt tells the browser how long to wait before reconnecting. Without it the\ndefault (~3 s) puts the retry inside a deploy window, where it gets a 502 —\nand a non-200 closes an `EventSource` permanently.\n\n`event: unread` — emitted once on connect with the current unread badge count.\n`data: {\"unread\": <integer>}`\n\n`event: notification` — emitted for each new notification created after the connection was opened (pushed in real time via Redis; ~20 s DB poll as a fallback). The data payload is identical to a single item from `GET /notifications`.\n`data: <Notification JSON object>`\n\n`: ping` — heartbeat comment emitted every ~15 s to keep proxies alive.\n\n**Implementation notes:**\n- Only notifications created *after* the SSE connection is established are\n  streamed. History is available via `GET /notifications`.\n\n- A fresh DB session is used per poll iteration; no session is held open\n  across sleep intervals.\n\n- The server-side generator checks for client disconnect on every loop\n  iteration and exits cleanly — there are no leaked tasks.\n\n- An infrastructure error mid-stream (DB pool exhausted, database down)\n  closes the stream cleanly instead of aborting the socket; the client\n  simply reconnects after the `retry` delay.",
        "responses": {
          "200": {
            "description": "SSE stream open. The body is a sequence of SSE frames as described above. The stream does not close on its own.",
            "content": {
              "text/event-stream": {
                "schema": {
                  "type": "string",
                  "description": "Raw SSE frames. Each frame is separated by a blank line. Use `EventSource.addEventListener('unread', …)` and `EventSource.addEventListener('notification', …)` to handle typed events."
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/notifications": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización activa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listNotifications",
        "x-tool": {
          "name": "list_notifications",
          "domain": "org",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "notifications"
        ],
        "summary": "Listar mis notificaciones",
        "description": "Notificaciones del usuario autenticado en esta organización, más recientes primero. member+. `limit` acota el tamaño (1..200, por defecto 50). La campana pide 50; la página «ver todas» pide más para paneles grandes. El badge (`unread-count`) sigue siendo la única fuente exacta del contador.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Máximo de notificaciones a devolver (1..200, por defecto 50).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Notificaciones (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Notification"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/notifications/unread-count": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización activa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "unreadNotificationCount",
        "x-tool": {
          "name": "notification_unread_count",
          "domain": "org",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "notifications"
        ],
        "summary": "Contar notificaciones sin leer",
        "description": "Número de notificaciones sin leer del usuario en esta organización. member+. Pensado para el badge de la campana (poll cada ~30 s).",
        "responses": {
          "200": {
            "description": "Contador sin leer.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UnreadCount"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/notifications/read-all": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización activa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "markAllNotificationsRead",
        "x-tool": {
          "name": "mark_all_notifications_read",
          "domain": "org",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "notifications"
        ],
        "summary": "Marcar todas como leídas",
        "description": "Marca como leídas todas las notificaciones sin leer del usuario en esta organización. member+. Devuelve el nuevo contador sin leer (`0`).",
        "responses": {
          "200": {
            "description": "Contador sin leer tras marcar todas (`0`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UnreadCount"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/notifications/{notification_id}/read": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización activa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "notification_id",
          "in": "path",
          "required": true,
          "description": "UUID de la notificación.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "markNotificationRead",
        "x-tool": {
          "name": "mark_notification_read",
          "domain": "org",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "notifications"
        ],
        "summary": "Marcar una notificación como leída",
        "description": "Marca como leída una notificación PROPIA del usuario. member+. `404` si la notificación no existe o es de otro usuario/organización (homogéneo).",
        "responses": {
          "200": {
            "description": "Notificación (ya marcada como leída).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Notification"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Notificación inexistente o ajena; u organización/no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/notifications/{notification_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización activa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "notification_id",
          "in": "path",
          "required": true,
          "description": "UUID de la notificación.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "deleteNotification",
        "x-tool": {
          "name": "delete_notification",
          "domain": "org",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "notifications"
        ],
        "summary": "Borrar una notificación",
        "description": "Borra una notificación PROPIA del usuario. member+. `404` si no existe o es de otro usuario/organización.",
        "responses": {
          "204": {
            "description": "Notificación borrada (sin cuerpo)."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Notificación inexistente o ajena; u organización/no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/notification-preferences": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listNotificationPreferences",
        "tags": [
          "notifications"
        ],
        "summary": "Preferencias efectivas del usuario (una por categoría)",
        "description": "Preferencia efectiva del usuario actual en cada categoría. Modelo opt-out: sin ajuste guardado, la categoría sale con todo activado. Cada elemento trae además `channel_limits`: los eventos de esa categoría cuyo reparto está acotado por código y que ignoran parte de los interruptores.",
        "responses": {
          "200": {
            "description": "Lista de preferencias efectivas (una por categoría).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/notification_preference"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada o sin acceso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "put": {
        "operationId": "upsertNotificationPreference",
        "tags": [
          "notifications"
        ],
        "summary": "Ajustar (upsert) la preferencia de una categoría",
        "description": "Crea o actualiza la preferencia de UNA categoría del usuario actual. Cada usuario gestiona solo las suyas.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/notification_preference_in"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Preferencia actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/notification_preference"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada o sin acceso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (categoría o horas fuera de rango).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/push-subscriptions": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "registerPushSubscription",
        "tags": [
          "notifications"
        ],
        "summary": "Registrar suscripción Web Push",
        "description": "Registra la suscripción Web Push de este navegador para el usuario actual. Idempotente por `endpoint` (re-suscribirse no duplica).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/push_subscription_in"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Suscripción registrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/push_subscription"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada o sin acceso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deletePushSubscription",
        "tags": [
          "notifications"
        ],
        "summary": "Dar de baja una suscripción Web Push",
        "description": "Elimina la suscripción de este navegador (por `endpoint`) del usuario.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/push_subscription_delete_in"
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Suscripción borrada (o no existía)."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada o sin acceso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/device-tokens": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "registerDeviceToken",
        "tags": [
          "notifications"
        ],
        "summary": "Registrar token de push nativo",
        "description": "Registra el token de push nativo (APNs/FCM) de este dispositivo para el usuario actual. Idempotente por `token` (re-registrar no duplica).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/device_token_in"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Token registrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/device_token"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada o sin acceso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteDeviceToken",
        "tags": [
          "notifications"
        ],
        "summary": "Dar de baja un token de push nativo",
        "description": "Elimina el token de este dispositivo (por `token`) del usuario.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/device_token_delete_in"
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Token borrado (o no existía)."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada o sin acceso.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/channels": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "createChatChannel",
        "tags": [
          "chat"
        ],
        "summary": "Crear canal de chat",
        "description": "Crea un canal DM (idempotente entre 2 miembros), grupo o de proyecto. El creador queda como owner.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/chat_channel_create_in"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Canal creado (o el DM existente).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/chat_channel_detail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Org/recurso no encontrado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "get": {
        "operationId": "listChatChannels",
        "tags": [
          "chat"
        ],
        "summary": "Listar mis canales",
        "description": "Canales de la org donde soy miembro, no archivados.",
        "responses": {
          "200": {
            "description": "Lista de canales.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/chat_channel"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Org no encontrada",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/channels/browse": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "browseChatChannels",
        "tags": [
          "chat"
        ],
        "summary": "Explorar canales de la organización",
        "description": "Canales persistentes (type=channel) visibles para mí: todos los públicos de la org + los privados donde soy miembro. Incluye `is_member` y `member_count`. No incluye DMs/grupos/canales de proyecto (esos van en «mis canales»).\n\nPaginado: el número de canales públicos crece con el uso, así que la respuesta viene acotada SIEMPRE (200 filas si no se pide otra cosa) y `X-Total-Count` trae el total sin paginar. El body sigue siendo el array plano de siempre.",
        "parameters": [
          {
            "name": "include_archived",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "default": false
            },
            "description": "Incluir también los canales archivados."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Canales visibles para mí.",
            "headers": {
              "X-Total-Count": {
                "description": "Total de canales visibles (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/chat_channel"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Org no encontrada",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/channels/{channel_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "channel_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getChatChannel",
        "tags": [
          "chat"
        ],
        "summary": "Detalle de canal + miembros",
        "responses": {
          "200": {
            "description": "Canal con miembros.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/chat_channel_detail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal no encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateChatChannel",
        "tags": [
          "chat"
        ],
        "summary": "Editar el canal",
        "description": "Cambia nombre, descripción, icono y (en type=channel) la visibilidad público/privado. Los DM no se editan. En type=channel solo owner/moderador del canal (u owner/admin de la org); en group/project cualquier miembro. Se aplican solo los campos presentes en el cuerpo.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/chat_channel_update_in"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Canal actualizado con sus miembros.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/chat_channel_detail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal no encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteChatChannel",
        "tags": [
          "chat"
        ],
        "summary": "Eliminar canal (solo type=channel)",
        "description": "Borra definitivamente un canal estilo Discord con sus mensajes y adjuntos (PJKT-1957). Solo el owner del canal o un owner/admin de la org. Los DM/grupos/canales de proyecto no se borran (usar archivar) → 422.",
        "responses": {
          "204": {
            "description": "Canal eliminado."
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal no encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/channels/{channel_id}/join": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "channel_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "joinChatChannel",
        "tags": [
          "chat"
        ],
        "summary": "Unirme a un canal público",
        "description": "Me une a un canal público (type=channel) de la org; idempotente si ya soy miembro (de cualquier tipo de canal). Un canal privado del que no soy miembro responde 404 homogéneo.",
        "responses": {
          "200": {
            "description": "Canal con sus miembros (yo incluido).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/chat_channel_detail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal no encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/channels/{channel_id}/unarchive": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "channel_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "unarchiveChatChannel",
        "tags": [
          "chat"
        ],
        "summary": "Desarchivar canal",
        "description": "Reactiva un canal archivado. Misma autorización que archivar: owner del canal (en type=channel, también un owner/admin de la organización).",
        "responses": {
          "204": {
            "description": "Canal desarchivado."
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal no encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/channels/{channel_id}/members": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "channel_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "addChatChannelMembers",
        "tags": [
          "chat"
        ],
        "summary": "Añadir miembros al canal",
        "description": "En group/project cualquier miembro del canal. En type=channel solo owner/moderador del canal (u owner/admin de la org).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/chat_add_members_in"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Canal actualizado con sus miembros.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/chat_channel_detail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal no encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/channels/{channel_id}/members/{user_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "channel_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "user_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "removeChatChannelMember",
        "tags": [
          "chat"
        ],
        "summary": "Quitar miembro del canal",
        "description": "A mí mismo siempre (salir). A otros: en group/project solo el owner; en type=channel también moderadores (que no pueden expulsar al owner ni a otro moderador) y owner/admin de la org.",
        "responses": {
          "204": {
            "description": "Miembro quitado."
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal/miembro no encontrado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateChatChannelMemberRole",
        "tags": [
          "chat"
        ],
        "summary": "Cambiar el rol de un miembro (solo type=channel)",
        "description": "Promociona/degrada moderadores de un canal estilo Discord (PJKT-1957). Solo el owner del canal o un owner/admin de la org; el rol del owner es inmutable. En otros tipos de canal → 422.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/chat_member_role_in"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Canal actualizado con sus miembros.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/chat_channel_detail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal/miembro no encontrado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/channels/{channel_id}/archive": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "channel_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "archiveChatChannel",
        "tags": [
          "chat"
        ],
        "summary": "Archivar canal",
        "description": "Solo el owner del canal (en type=channel, también un owner/admin de la org). Un canal archivado bloquea la escritura de mensajes (409).",
        "responses": {
          "204": {
            "description": "Canal archivado."
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal no encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/channels/{channel_id}/messages": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "channel_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "sendChatMessage",
        "tags": [
          "chat"
        ],
        "summary": "Enviar mensaje",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/chat_message_create_in"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Mensaje creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/chat_message"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal no encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "get": {
        "operationId": "listChatMessages",
        "tags": [
          "chat"
        ],
        "summary": "Listar mensajes (cursor)",
        "parameters": [
          {
            "name": "before_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Mensajes en orden cronológico.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/chat_message"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal no encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/channels/{channel_id}/messages/{message_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "channel_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "message_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "editChatMessage",
        "tags": [
          "chat"
        ],
        "summary": "Editar mensaje (autor)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/chat_message_edit_in"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Mensaje editado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/chat_message"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteChatMessage",
        "tags": [
          "chat"
        ],
        "summary": "Borrar mensaje (autor u owner)",
        "responses": {
          "204": {
            "description": "Mensaje borrado."
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/voice-channels": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listChatVoiceChannels",
        "tags": [
          "chat"
        ],
        "summary": "Listar canales de audio con su ocupación",
        "description": "Canales de audio visibles para mí (todos los públicos de la org + los privados donde soy miembro), cada uno con quién está dentro AHORA. Es la barra lateral: se ve la ocupación antes de entrar. Los archivados quedan fuera salvo que se pidan.\n\nAcepta `limit`/`offset` pero NO devuelve `X-Total-Count` (la cabecera es opcional, ver `docs/api-contract.md`): quien pinte la barra lateral no puede calcular el número de páginas a partir de esta respuesta. Es deliberado —una barra lateral de canales de audio se pinta entera— y añadir la cabecera más adelante no sería un cambio breaking.",
        "parameters": [
          {
            "name": "include_archived",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "default": false
            },
            "description": "Incluir también los canales de audio archivados."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Canales de audio visibles, con su censo actual.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/chat_voice_channel"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Org no encontrada",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/channels/{channel_id}/voice": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "channel_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getChatVoiceRoom",
        "tags": [
          "chat"
        ],
        "summary": "Quién está en el audio de este canal",
        "description": "Censo de la sala de audio del canal. Lo puede leer cualquiera que pueda leer el canal (miembro, o cualquier miembro de la org si el canal es público): mirar quién hay dentro es previo a entrar. Un canal sin audio responde 422 `voice_disabled`; un canal que no puedo leer, 404 homogéneo.",
        "responses": {
          "200": {
            "description": "Estado de la sala.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/chat_voice_room"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal no encontrado (o sin acceso)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "El canal no tiene audio habilitado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "leaveChatVoiceRoom",
        "tags": [
          "chat"
        ],
        "summary": "Salir del audio del canal",
        "description": "Me quita del censo de la sala. Idempotente: salir de una sala en la que no estoy es 204 igual. No corta la conexión LiveKit (eso lo hace el cliente al desconectar); si el cliente muere sin llamar aquí, el TTL lo saca solo.",
        "responses": {
          "204": {
            "description": "Fuera del censo."
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal no encontrado (o sin acceso)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "El canal no tiene audio habilitado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/channels/{channel_id}/voice/join": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "channel_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "joinChatVoiceRoom",
        "tags": [
          "chat"
        ],
        "summary": "Entrar al audio del canal",
        "description": "Firma un token LiveKit para la sala permanente del canal y me añade al censo. Entrar exige poder ESCRIBIR en el canal: miembro del canal, o cualquier miembro de la org si el canal es público (en ese caso se crea la fila de miembro, igual que al escribir un mensaje). Un canal privado del que no soy miembro es 404 homogéneo. Idempotente: entrar dos veces devuelve otro token válido y no duplica al participante.\n\nSi esta instalación no tiene LiveKit configurado responde 503 `livekit_unconfigured`, exactamente como el token de reunión.",
        "responses": {
          "200": {
            "description": "Token de acceso + censo de la sala.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/chat_voice_join"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal no encontrado (o sin acceso)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "El canal no tiene audio habilitado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "LiveKit no configurado en esta instalación",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/channels/{channel_id}/voice/heartbeat": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "channel_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "chatVoiceHeartbeat",
        "tags": [
          "chat"
        ],
        "summary": "Sigo en el audio de este canal",
        "description": "Refresca mi TTL en el censo. El cliente lo llama mientras esté conectado a la sala (cadencia recomendada: cada 15 s; el TTL es de 45 s). Si deja de llamarlo, desaparezco del censo sin que nadie tenga que avisar. Latir sin haber entrado no me mete en la sala: responde 204 y no hace nada.",
        "responses": {
          "204": {
            "description": "TTL refrescado."
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal no encontrado (o sin acceso)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "El canal no tiene audio habilitado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/external/people": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización desde la que se actúa (la del emisor).",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "searchExternalChatPeople",
        "tags": [
          "chat"
        ],
        "summary": "Buscar personas de otras organizaciones con las que hablar",
        "description": "Personas de organizaciones DISTINTAS a la del path cuya organización tiene la mensajería externa activada, y siempre que la organización del path también la tenga activada (si no, `403`). Se excluye a quien ya comparte organización con quien busca: ésos ya están en el directorio normal del chat.\n\n`q` es **obligatorio y de 2 caracteres mínimo**: sin él, el endpoint sería un censo paginable de todas las personas de todas las organizaciones abiertas. Buscar a alguien concreto es el caso de uso; enumerar a todo el mundo no lo es.",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "description": "Nombre (o parte) de la persona. Mínimo 2 caracteres.",
            "schema": {
              "type": "string",
              "minLength": 2
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Máximo de resultados (1..50, por defecto 20).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 50,
              "default": 20
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Personas alcanzables (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/chat_external_person"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "La organización del path tiene la mensajería externa desactivada (`external_messaging_disabled`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No eres miembro de la organización del path (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`q` ausente o de menos de 2 caracteres.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/external/conversations": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización desde la que se actúa (la del emisor).",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "openExternalChatConversation",
        "tags": [
          "chat"
        ],
        "summary": "Abrir un directo con alguien de otra organización",
        "description": "Crea (o devuelve, si ya existe) el canal `dm` entre quien llama y la persona indicada. El canal queda marcado `is_cross_org: true` y aparece en `GET /chat/channels` de LAS DOS organizaciones — cada participante lo ve desde la suya. A partir de ahí se usa con los endpoints normales de mensajes.\n\nResponde `403 external_messaging_disabled` si cualquiera de las dos organizaciones tiene la puerta cerrada, y `404` si la persona no existe, no es miembro de la organización que dice el cuerpo, o ya comparte organización con quien llama (para eso está el DM normal).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/chat_external_dm_in"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Canal creado (o el que ya existía).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/chat_channel_detail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Alguna de las dos organizaciones tiene la mensajería externa desactivada (`external_messaging_disabled`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No eres miembro de la organización del path, o la persona no existe / no es miembro de la organización indicada (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/stream": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "streamChat",
        "tags": [
          "chat"
        ],
        "summary": "Stream de chat en tiempo real (SSE)",
        "description": "Server-Sent Events. Emite `event: message` por cada mensaje nuevo en cualquiera de mis canales, y `: ping` heartbeat cada ~15 s.",
        "responses": {
          "200": {
            "description": "Stream SSE (text/event-stream).",
            "content": {
              "text/event-stream": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Org no encontrada",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/unread-count": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "chatUnreadCount",
        "tags": [
          "chat"
        ],
        "summary": "Total de no-leídos (badge)",
        "responses": {
          "200": {
            "description": "Total de mensajes no leídos en mis canales.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/chat_unread_count"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Org no encontrada",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/channels/{channel_id}/read": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "channel_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "markChatChannelRead",
        "tags": [
          "chat"
        ],
        "summary": "Marcar canal como leído",
        "responses": {
          "204": {
            "description": "Marcado leído."
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal no encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/channels/{channel_id}/unread": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "channel_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "markChatChannelUnread",
        "tags": [
          "chat"
        ],
        "summary": "Marcar canal como NO leído",
        "responses": {
          "204": {
            "description": "Marcado no leído."
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal no encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/channels/{channel_id}/messages/{message_id}/reactions": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "channel_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "message_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "addChatReaction",
        "tags": [
          "chat"
        ],
        "summary": "Reaccionar (emoji)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/chat_reaction_in"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Mensaje con reacciones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/chat_message"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/channels/{channel_id}/messages/{message_id}/reactions/{emoji}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "channel_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "message_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "emoji",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "delete": {
        "operationId": "removeChatReaction",
        "tags": [
          "chat"
        ],
        "summary": "Quitar mi reacción",
        "responses": {
          "200": {
            "description": "Mensaje con reacciones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/chat_message"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/channels/{channel_id}/pins": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "channel_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listChatPins",
        "tags": [
          "chat"
        ],
        "summary": "Listar mensajes pineados vigentes",
        "description": "Mensajes con pin vigente (pinned_until > ahora) del canal, recién pineados primero. Para el banner de fijados. Un pin caducado no aparece.",
        "responses": {
          "200": {
            "description": "Mensajes pineados vigentes.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/chat_message"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal no encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/channels/{channel_id}/messages/{message_id}/pin": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "channel_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "message_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "pinChatMessage",
        "tags": [
          "chat"
        ],
        "summary": "Pinear mensaje (duración configurable)",
        "description": "Fija un mensaje del canal hasta la fecha derivada de la duración (1w / 15d / 30d / forever). Cualquier miembro del canal puede pinear.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/chat_message_pin_in"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Mensaje con el pin aplicado (incluye pinned_until).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/chat_message"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal o mensaje no encontrado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Duración no válida",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "unpinChatMessage",
        "tags": [
          "chat"
        ],
        "summary": "Quitar el pin de un mensaje",
        "responses": {
          "200": {
            "description": "Mensaje con el pin quitado (pinned_until null).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/chat_message"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal o mensaje no encontrado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/channels/{channel_id}/messages/{message_id}/attachments": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "channel_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "message_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "uploadChatAttachment",
        "tags": [
          "chat"
        ],
        "summary": "Adjuntar fichero a un mensaje (multipart)",
        "description": "Sube un fichero como adjunto de un mensaje propio (solo el autor). Guardado vía StorageService. Límite 10 MB; tipos permitidos: png, jpg, jpeg, webp, gif, pdf. Devuelve el mensaje con la lista de adjuntos.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "Fichero a subir (máx 10 MB)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Mensaje con el adjunto añadido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/chat_message"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Solo el autor del mensaje puede adjuntar",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal o mensaje no encontrado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Fichero demasiado grande o tipo no permitido",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/attachments/{attachment_id}/download": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "attachment_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "downloadChatAttachment",
        "tags": [
          "chat"
        ],
        "summary": "Descargar adjunto de chat",
        "description": "Devuelve el contenido binario del adjunto con `Content-Disposition` y `Content-Type`. Solo miembros del canal del adjunto (si no, 404 homogéneo).",
        "responses": {
          "200": {
            "description": "Bytes del adjunto.",
            "content": {
              "application/octet-stream": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Adjunto no encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/channels/{channel_id}/typing": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "channel_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "chatTyping",
        "tags": [
          "chat"
        ],
        "summary": "Estoy escribiendo (efímero)",
        "description": "Publica un evento efímero `typing` a los otros miembros del canal (sin BD).",
        "responses": {
          "204": {
            "description": "Evento typing publicado."
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal no encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/channels/{channel_id}/mute": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "channel_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "muteChatChannel",
        "tags": [
          "chat"
        ],
        "summary": "Silenciar/activar canal",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/chat_mute_in"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Canal actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/chat_channel_detail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal no encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/heartbeat": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "chatHeartbeat",
        "tags": [
          "chat"
        ],
        "summary": "Heartbeat de presencia",
        "description": "Marca mi presencia online (TTL ~30 s). El cliente lo repite periódicamente.",
        "responses": {
          "204": {
            "description": "Presencia marcada."
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Org no encontrada",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/presence": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "chatPresence",
        "tags": [
          "chat"
        ],
        "summary": "Presencia (online / última conexión / estado) de usuarios",
        "parameters": [
          {
            "name": "user_ids",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "CSV de user_ids a consultar."
          }
        ],
        "responses": {
          "200": {
            "description": "Presencia por usuario (acotada a mi org).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/chat_user_presence"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Org no encontrada",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/chat/status": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "setChatStatus",
        "tags": [
          "chat"
        ],
        "summary": "Fijar mi estado de chat",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/chat_status_in"
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Estado actualizado."
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Org no encontrada",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/usage/me": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "myUsage",
        "tags": [
          "metering"
        ],
        "summary": "Mi consumo del mes en curso",
        "description": "Un item por recurso. Salen TODOS, incluidos los que esta edición no ofrece — con `included: null`. Es lo que deja a la pantalla decir «Kern es de Pro» en vez de no enseñar nada.",
        "responses": {
          "200": {
            "description": "Mi consumo del periodo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/usage_me"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/usage": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "resource",
          "in": "query",
          "required": true,
          "schema": {
            "type": "string",
            "enum": [
              "ia_tokens",
              "video_minutos",
              "ejecuciones"
            ]
          }
        },
        {
          "name": "period",
          "in": "query",
          "required": false,
          "description": "`YYYY-MM`. Por defecto, el mes en curso.",
          "schema": {
            "type": [
              "string",
              "null"
            ],
            "pattern": "^\\d{4}-\\d{2}$"
          }
        }
      ],
      "get": {
        "operationId": "usageBreakdown",
        "tags": [
          "metering"
        ],
        "summary": "Quién ha gastado qué (admin+)",
        "description": "De mayor a menor. Solo aparece quien tiene consumo: un listado con ochenta personas a cero para encontrar a las tres que gastan es un listado que nadie lee, y el que falta se deduce.",
        "responses": {
          "200": {
            "description": "El reparto del periodo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/usage_breakdown"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Requiere admin+",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Recurso o periodo inválido",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/usage/history": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "resource",
          "in": "query",
          "required": true,
          "schema": {
            "type": "string",
            "enum": [
              "ia_tokens",
              "video_minutos",
              "ejecuciones"
            ]
          }
        },
        {
          "name": "months",
          "in": "query",
          "required": false,
          "description": "Cuántos meses hacia atrás, contando el actual. Tope 24 — dos años son todo lo que un panel de consumo puede pintar sin convertirse en un informe, y el tope impide además que alguien pida diez mil.",
          "schema": {
            "type": "integer",
            "minimum": 1,
            "maximum": 24,
            "default": 12
          }
        }
      ],
      "get": {
        "operationId": "usageHistory",
        "tags": [
          "metering"
        ],
        "summary": "El consumo de la organización, mes a mes (admin+)",
        "description": "Del más reciente al más antiguo. Los meses SIN consumo NO salen: no hay fila que sumar, y quien pinte la serie tiene que rellenar los huecos con cero — una gráfica que se salta un mes vacío miente sobre la pendiente.",
        "responses": {
          "200": {
            "description": "El histórico.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/usage_history"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Requiere admin+",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Recurso o número de meses inválido",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/meetings": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "createMeeting",
        "x-tool": {
          "name": "create_meeting",
          "domain": "calendar",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "meetings"
        ],
        "summary": "Crear reunión",
        "description": "Crea una reunión (agendada si trae scheduled_start; si no, instantánea). Con `client_id` queda ligada a un cliente de la organización y aparece en el timeline de su ficha.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/meeting_create_in"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Reunión creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/meeting"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Una de las dos organizaciones cerró la puerta (external_messaging_disabled). Solo en una llamada colgada de un DM entre organizaciones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Canal o cliente no encontrado, o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "get": {
        "operationId": "listMeetings",
        "tags": [
          "meetings"
        ],
        "summary": "Listar reuniones",
        "description": "Reuniones de la organización, de la más reciente a la más antigua. Sin parámetros devuelve las 200 últimas: hasta el 06/09/2026 bajaba el histórico ENTERO, y lo hacía en seis pantallas —el calendario del escritorio y el del teléfono, las dos listas de reuniones, el panel de Mensajes y el reloj de la isla— que después lo recortaban en el cliente.\nEl momento de una reunión es `scheduled_start` si está agendada y `created_at` si fue instantánea (el mismo `COALESCE` con el que se ordena), y es contra ese momento contra el que filtran `start` y `end`.\nLos dos son INSTANTES y no fechas, a diferencia de `listCalendarEvents`: una fecha necesita una zona horaria para convertirse en un rango, y quien sabe qué día se está pintando es la pantalla —que ya tiene la zona de la organización—, no este endpoint.",
        "parameters": [
          {
            "name": "start",
            "in": "query",
            "required": false,
            "description": "Solo las reuniones cuyo momento sea posterior o igual a este instante.",
            "schema": {
              "type": [
                "string",
                "null"
              ],
              "format": "date-time"
            }
          },
          {
            "name": "end",
            "in": "query",
            "required": false,
            "description": "Solo las reuniones cuyo momento sea anterior o igual a este instante.",
            "schema": {
              "type": [
                "string",
                "null"
              ],
              "format": "date-time"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Máximo de elementos a devolver (1..200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Reuniones de la organización (más recientes primero).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/meeting"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Org no encontrada",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/meetings/invitations": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listMyMeetingInvitations",
        "tags": [
          "meetings"
        ],
        "summary": "Mis invitaciones a reuniones",
        "description": "Lista las invitaciones del usuario autenticado en esta org (con el resumen de la reunión).",
        "responses": {
          "200": {
            "description": "Invitaciones del usuario.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/meeting_invitation"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Org no encontrada",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/meetings/capabilities": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getMeetingCapabilities",
        "tags": [
          "meetings"
        ],
        "summary": "¿Tiene vídeo esta instalación?",
        "description": "Dice si LiveKit está configurado en ESTA instalación (variables LIVEKIT_*). No mira el plan ni el rol: es una propiedad del despliegue. La interfaz lo usa para avisar antes de crear una reunión en vez de chocar con el 503 `livekit_unconfigured` al pedir el token.",
        "responses": {
          "200": {
            "description": "Capacidades de vídeo de la instalación.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/meeting_capabilities"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Org no encontrada",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/meetings/{meeting_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "meeting_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getMeeting",
        "tags": [
          "meetings"
        ],
        "summary": "Detalle de una reunión que alcanzo",
        "description": "De mi organización, o colgada de un canal de chat que alcanzo desde ella — que es como una llamada ENTRE organizaciones llega al otro lado, cuya organización nunca fue la de la reunión.",
        "responses": {
          "200": {
            "description": "Reunión.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/meeting"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Una de las dos organizaciones cerró la puerta (external_messaging_disabled). Solo en una llamada colgada de un DM entre organizaciones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrada o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateMeeting",
        "tags": [
          "meetings"
        ],
        "summary": "Editar una reunión",
        "description": "Cambia título y horas. Solo el ANFITRIÓN o un owner/admin de la organización (`can_manage_meeting`, la misma puerta que invitar y admitir): la hora de una reunión es un compromiso con las personas invitadas, y moverla no puede estar al alcance de cualquiera que reciba el enlace.\n\n422 `meeting_already_started` si la reunión ya empezó. Reprogramar algo que está ocurriendo no cambia lo que ocurre: solo hace que la ficha mienta sobre lo que pasó, y este listado es también el histórico.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/meeting_update_in"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "La reunión ya editada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/meeting"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No eres el anfitrión ni admin",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrada o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Ya empezó (meeting_already_started)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteMeeting",
        "tags": [
          "meetings"
        ],
        "summary": "Eliminar una reunión",
        "description": "La borra, con sus invitaciones y admisiones (cascada). Mismo permiso que el PATCH.\n\nBorra DE VERDAD y no marca «cancelada», y es una decisión: una reunión que nunca llegó a ocurrir no es histórico de nada, y dejarla como fila muerta obligaría a filtrarla en el calendario, en el listado, en la ficha del cliente y en el timeline. El histórico de las que SÍ ocurrieron lo sostienen `started_at`/`ended_at`, y a ésas el 422 de abajo las protege.\n\n422 `meeting_already_started` si ya empezó: eso ya no es cancelar una cita, es borrar lo que pasó.",
        "responses": {
          "204": {
            "description": "Eliminada."
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No eres el anfitrión ni admin",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrada o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Ya empezó (meeting_already_started)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/meetings/{meeting_id}/start": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "meeting_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "startMeeting",
        "tags": [
          "meetings"
        ],
        "summary": "Marcar que la reunión ha empezado de verdad",
        "description": "Pone `started_at` y, si estaba `scheduled`, `status: live`. IDEMPOTENTE: la primera llamada fija la hora y el resto devuelve la misma reunión.\n\nLo dispara el `onConnected` de la sala, o sea cuando alguien CONECTA de verdad. Antes esto lo hacía el endpoint de token, y era el sitio equivocado: la página de la reunión pide el token al abrirse, así que abrir el enlace para ver de qué va la marcaba empezada — y como una reunión empezada no se puede editar ni borrar, la dejaba inmutable sin que nadie hubiera entrado.\n\nSimétrico de `/end`, que dispara el `onDisconnected`: el principio y el final los marca quien entra y quien sale.",
        "responses": {
          "200": {
            "description": "La reunión con su `started_at`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/meeting"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin acceso a la sala",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrada o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/meetings/{meeting_id}/end": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "meeting_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "endMeeting",
        "tags": [
          "meetings"
        ],
        "summary": "Dar la reunión por terminada",
        "description": "Pone `ended_at` y `status: ended` CUANDO YA NO QUEDA NADIE en la sala. Lo llama el `onDisconnected`, que dispara una vez por participante: en una reunión de cinco llegan cinco peticiones, y las cuatro primeras no cierran nada porque LiveKit dice que aún hay gente dentro.\n\nAntes cerraba la primera, y la duración era la del primero que se iba: una reunión de una hora de la que alguien sale a los cinco minutos duraba cinco minutos. A quien pregunta se le descuenta del recuento, porque su propia desconexión puede tardar un instante en verse desde el servidor.\n\nPuede cerrarla cualquiera que pudiera ENTRAR, no solo el anfitrión, y es una decisión y no una relajación: quien cierra es el último que se va, que casi nunca es quien la creó. Exigir anfitrión dejaría sin cerrar toda reunión de la que el anfitrión se va antes — o sea, la mayoría — y `ended` volvería a ser un estado que nadie escribe.",
        "responses": {
          "200": {
            "description": "La reunión ya cerrada, con su `ended_at`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/meeting"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin acceso a la sala",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrada o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/meetings/{meeting_id}/token": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "meeting_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "mintMeetingToken",
        "tags": [
          "meetings"
        ],
        "summary": "Token de acceso a la sala (LiveKit)",
        "description": "Firma un JWT de acceso LiveKit para unirme a la sala de la reunión. Un invitado sin acceso directo debe estar admitido (sala de espera): mientras espera devuelve 403 `admission_required`, y si el anfitrión lo rechazó, 403 `admission_denied`.",
        "responses": {
          "200": {
            "description": "Token + datos de conexión.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/meeting_join_token"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Requiere admisión (admission_required) o rechazado (admission_denied)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrada o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "LiveKit no configurado en este entorno",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/meetings/{meeting_id}/invites": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "meeting_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "createMeetingInvites",
        "tags": [
          "meetings"
        ],
        "summary": "Invitar miembros a una reunión",
        "description": "Invita a uno o más miembros de la org (solo el anfitrión o un owner/admin). Cada invitado recibe una notificación `meeting_invite`. Idempotente: si ya estaba invitado se devuelve su invitación existente.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/meeting_invite_create_in"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Invitaciones creadas (o ya existentes).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/meeting_invite"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Solo el anfitrión o un admin puede invitar",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Reunión no encontrada o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Algún user_id no es miembro de la org",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "get": {
        "operationId": "listMeetingInvites",
        "tags": [
          "meetings"
        ],
        "summary": "Listar invitaciones de una reunión",
        "responses": {
          "200": {
            "description": "Invitaciones de la reunión.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/meeting_invite"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Reunión no encontrada o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/meetings/{meeting_id}/invites/respond": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "meeting_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "respondMeetingInvite",
        "tags": [
          "meetings"
        ],
        "summary": "Responder a mi invitación (RSVP)",
        "description": "Acepta o rechaza mi propia invitación a la reunión.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/meeting_invite_respond_in"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Invitación actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/meeting_invite"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No tienes invitación a esta reunión",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/meetings/{meeting_id}/knock": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "meeting_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "knockMeeting",
        "tags": [
          "meetings"
        ],
        "summary": "Pedir entrada a la sala (llamar a la puerta)",
        "description": "Un invitado sin acceso directo pide entrar; se crea (o se devuelve) su solicitud de admisión y se avisa al anfitrión. Quien ya tiene acceso directo recibe 409 (no necesita admisión); quien no está invitado, 404.",
        "responses": {
          "201": {
            "description": "Solicitud de admisión creada o existente.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/meeting_admission"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Reunión no encontrada o no estás invitado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ya tienes acceso directo (no necesitas admisión)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/meetings/{meeting_id}/admissions": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "meeting_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listMeetingAdmissions",
        "tags": [
          "meetings"
        ],
        "summary": "Listar solicitudes de admisión (anfitrión)",
        "description": "Solo el anfitrión o un owner/admin ve la sala de espera.",
        "responses": {
          "200": {
            "description": "Solicitudes pendientes de admisión.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/meeting_admission"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Solo el anfitrión o un admin ve las solicitudes",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Reunión no encontrada o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/meetings/{meeting_id}/admissions/{admission_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "meeting_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "admission_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "decideMeetingAdmission",
        "tags": [
          "meetings"
        ],
        "summary": "Admitir o rechazar una solicitud (anfitrión)",
        "description": "Solo el anfitrión o un owner/admin. Al admitir, el invitado obtiene acceso a la sala; al rechazar, no obtiene token. Se avisa al solicitante.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/meeting_admission_decide_in"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Solicitud actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/meeting_admission"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Solo el anfitrión o un admin puede decidir",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Reunión o solicitud no encontrada",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/meetings/by-slug/{slug}": {
      "parameters": [
        {
          "name": "slug",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getMeetingBySlug",
        "tags": [
          "meetings"
        ],
        "summary": "Resolver reunión por su link único (slug)",
        "description": "Devuelve la reunión del link `/meet/{slug}`. Dos formas de alcanzarla: ser miembro de su organización, o serlo del canal de chat del que cuelga — que es como una llamada ENTRE organizaciones llega al otro lado.",
        "responses": {
          "200": {
            "description": "Reunión.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/meeting"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Una de las dos organizaciones cerró la puerta (external_messaging_disabled). El enlace sigue siendo pulsable en el correo de ayer.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrada o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/okr/objectives": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "createObjective",
        "x-tool": {
          "name": "create_objective",
          "domain": "okr",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "okr"
        ],
        "summary": "Crear objetivo",
        "description": "Crea un objetivo (opcionalmente con sus KRs iniciales). Requiere manager+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/objective_create_in"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Objetivo creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/objective"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (requiere manager+)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Org u owner no encontrado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "get": {
        "operationId": "listObjectives",
        "x-tool": {
          "name": "list_objectives",
          "domain": "okr",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "okr"
        ],
        "summary": "Listar objetivos",
        "description": "Objetivos de la organización con sus KRs y progreso derivado. Requiere manager+.",
        "parameters": [
          {
            "name": "period",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 20
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "active",
                "done",
                "archived"
              ]
            }
          },
          {
            "name": "parent_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Filtra los hijos alineados bajo este objetivo padre (jerarquía OKR)."
          }
        ],
        "responses": {
          "200": {
            "description": "Objetivos (más recientes primero).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/objective"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (requiere manager+)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Org no encontrada",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/okr/objectives/{objective_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "objective_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getObjective",
        "x-tool": {
          "name": "get_objective",
          "domain": "okr",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "okr"
        ],
        "summary": "Detalle de objetivo",
        "description": "Objetivo con sus KRs (solo de mi organización). Requiere manager+.",
        "responses": {
          "200": {
            "description": "Objetivo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/objective"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (requiere manager+)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateObjective",
        "x-tool": {
          "name": "update_objective",
          "domain": "okr",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "okr"
        ],
        "summary": "Editar objetivo",
        "description": "Edita un objetivo (parcial). Requiere manager+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/objective_update_in"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Objetivo actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/objective"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (requiere manager+)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteObjective",
        "tags": [
          "okr"
        ],
        "summary": "Borrar objetivo",
        "description": "Borra un objetivo y sus KRs (cascade). Requiere manager+.",
        "responses": {
          "204": {
            "description": "Borrado."
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (requiere manager+)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/okr/objectives/{objective_id}/key-results": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "objective_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "createKeyResult",
        "x-tool": {
          "name": "create_key_result",
          "domain": "okr",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "okr"
        ],
        "summary": "Añadir resultado clave",
        "description": "Añade un resultado clave a un objetivo. Requiere manager+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/key_result_create_in"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "KR creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/key_result"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (requiere manager+)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Objetivo no encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/okr/objectives/{objective_id}/key-results/{key_result_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "objective_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "key_result_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "updateKeyResult",
        "x-tool": {
          "name": "update_key_result",
          "domain": "okr",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "okr"
        ],
        "summary": "Editar resultado clave",
        "description": "Edita un KR (mover current_value refleja el avance). Requiere manager+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/key_result_update_in"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "KR actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/key_result"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (requiere manager+)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteKeyResult",
        "tags": [
          "okr"
        ],
        "summary": "Borrar resultado clave",
        "description": "Borra un resultado clave. Requiere manager+.",
        "responses": {
          "204": {
            "description": "Borrado."
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (requiere manager+)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/okr/objectives/{objective_id}/key-results/{key_result_id}/snapshots": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "objective_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "key_result_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "createKeyResultSnapshot",
        "x-tool": {
          "name": "register_key_result_snapshot",
          "domain": "okr",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "okr"
        ],
        "summary": "Registrar snapshot de progreso",
        "description": "Registra un punto de progreso: mueve current_value y deja rastro en el historial (G10). Requiere manager+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/key_result_snapshot_create_in"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Snapshot registrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/key_result_snapshot"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (requiere manager+)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Objetivo o KR no encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "get": {
        "operationId": "listKeyResultSnapshots",
        "x-tool": {
          "name": "list_key_result_snapshots",
          "domain": "okr",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "okr"
        ],
        "summary": "Historial de progreso de un resultado clave",
        "description": "Historial cronológico ascendente del value/target_value del KR — para pintar una gráfica de evolución del avance (G10). Requiere manager+.",
        "responses": {
          "200": {
            "description": "Snapshots del KR, más antiguo primero.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/key_result_snapshot"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (requiere manager+)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Objetivo o KR no encontrado o sin acceso",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/notifications/vapid-public-key": {
      "get": {
        "operationId": "getVapidPublicKey",
        "tags": [
          "notifications"
        ],
        "summary": "Clave pública VAPID para Web Push",
        "description": "Devuelve la clave pública VAPID (applicationServerKey). Pública por diseño. `\"\"` si el servidor no tiene Web Push configurado.",
        "responses": {
          "200": {
            "description": "Clave pública (posiblemente vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/vapid_public_key"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/hr/leave-requests": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organizacion.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listLeaveRequests",
        "x-tool": {
          "name": "list_leave_requests",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "hr"
        ],
        "summary": "Listar solicitudes de ausencia",
        "description": "Solicitudes de ausencia de la organizacion; requiere ser miembro. Filtrables por `status`, `employee_id` y rango de fechas (`from`/`to`).",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Estado: pending, approved, rejected.",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "approved",
                "rejected"
              ]
            }
          },
          {
            "name": "employee_id",
            "in": "query",
            "required": false,
            "description": "Filtra por empleado.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "start_date >= fecha (inclusive).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "end_date <= fecha (inclusive).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Máximo de elementos a devolver (1..200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de solicitudes (puede ser vacia).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/LeaveRequest"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organizacion inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createLeaveRequest",
        "x-tool": {
          "name": "create_leave_request",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "hr"
        ],
        "summary": "Crear solicitud de ausencia",
        "description": "Crea una solicitud de ausencia para un empleado. Los dias laborables se calculan en servidor (sin contar fines de semana). Un rango sin dias laborables devuelve 422. Requiere member+ para pedir PARA UNO MISMO; pedir en nombre de OTRA persona exige admin+ o ser su responsable en la cadena de mando (`employees.manager_id`, hasta 20 niveles) — exactamente la misma regla que aprobar esa ausencia. Hasta el 2026-09-10 bastaba member+ con cualquier `employee_id` de la organizacion, o sea que un miembro podia registrar una baja a nombre de un companero. Que la ficha sea la tuya se decide por EMAIL, que es el unico puente cuenta-ficha que hay (`employees` no tiene `user_id`); por eso una ficha sin cuenta solo admite peticiones de un admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "employee_id",
                  "leave_type",
                  "start_date",
                  "end_date"
                ],
                "properties": {
                  "employee_id": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "leave_type": {
                    "type": "string",
                    "enum": [
                      "vacation",
                      "sick",
                      "unpaid",
                      "other"
                    ]
                  },
                  "start_date": {
                    "type": "string",
                    "format": "date"
                  },
                  "end_date": {
                    "type": "string",
                    "format": "date",
                    "description": "Debe ser >= start_date."
                  },
                  "reason": {
                    "type": [
                      "string",
                      "null"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Solicitud creada (estado `pending`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LeaveRequest"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`) o el plan de la organizacion no incluye el modulo de RRHH (`feature_not_in_plan`). Manda `employee_id` de OTRA persona y no eres admin+ ni su responsable en la cadena de mando: `forbidden`. Un `member` solo puede pedir para si mismo; un `manager`, para si mismo y para quien cuelgue de el.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organizacion o empleado inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo invalido o sin dias laborables (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/hr/leave-requests/{request_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organizacion.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "request_id",
          "in": "path",
          "required": true,
          "description": "UUID de la solicitud.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getLeaveRequest",
        "x-tool": {
          "name": "get_leave_request",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "hr"
        ],
        "summary": "Detalle de solicitud de ausencia",
        "responses": {
          "200": {
            "description": "Solicitud de ausencia.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LeaveRequest"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Solicitud u organizacion inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "cancelLeaveRequest",
        "x-tool": {
          "name": "cancel_leave_request",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "hr"
        ],
        "summary": "Cancelar solicitud de ausencia",
        "description": "Elimina una solicitud pendiente. Un admin puede cancelar cualquier solicitud pendiente; un member solo la suya propia. Devuelve 422 si el estado no es `pending`.",
        "responses": {
          "204": {
            "description": "Solicitud cancelada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Solicitud de otro usuario y el rol no es admin+ (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Solicitud u organizacion inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "La solicitud no esta en estado `pending` (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/hr/leave-requests/{request_id}/approve": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organizacion.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "request_id",
          "in": "path",
          "required": true,
          "description": "UUID de la solicitud.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "approveLeaveRequest",
        "x-tool": {
          "name": "approve_leave_request",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "hr"
        ],
        "summary": "Aprobar solicitud de ausencia",
        "description": "Cambia el estado a `approved`; notifica al solicitante. Solo `pending` puede aprobarse (422 si no). Requiere admin+.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "comment": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "maxLength": 500
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Solicitud aprobada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LeaveRequest"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Solicitud u organizacion inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "La solicitud no esta en estado `pending` (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/hr/leave-requests/{request_id}/reject": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organizacion.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "request_id",
          "in": "path",
          "required": true,
          "description": "UUID de la solicitud.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "rejectLeaveRequest",
        "x-tool": {
          "name": "reject_leave_request",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "hr"
        ],
        "summary": "Rechazar solicitud de ausencia",
        "description": "Cambia el estado a `rejected`; notifica al solicitante. Solo `pending` puede rechazarse (422 si no). Requiere admin+.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "comment": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "maxLength": 500
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Solicitud rechazada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LeaveRequest"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Solicitud u organizacion inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "La solicitud no esta en estado `pending` (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/hr/leave-balances": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organizacion.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "leaveBalances",
        "x-tool": {
          "name": "leave_balances",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "hr"
        ],
        "summary": "Balances de vacaciones por empleado",
        "description": "Balance de vacaciones de todos los empleados activos para el ano indicado: dias asignados, tomados, pendientes y restantes. Requiere member+.",
        "parameters": [
          {
            "name": "year",
            "in": "query",
            "required": true,
            "description": "Ano del balance (YYYY).",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Balance por empleado activo.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/LeaveBalance"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organizacion inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parametro `year` requerido o invalido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/hr/absence-calendar": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organizacion.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "absenceCalendar",
        "x-tool": {
          "name": "absence_calendar",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "hr"
        ],
        "summary": "Calendario de ausencias del equipo",
        "description": "Ausencias aprobadas del equipo que solapan con el rango [start, end]. Util para vistas de calendario de equipo. Requiere member+. OJO con `leave_type`: para quien no es manager+ sale `null` en toda ausencia que no sea la suya. La ausencia se sigue viendo; el motivo, no.",
        "parameters": [
          {
            "name": "start",
            "in": "query",
            "required": true,
            "description": "Inicio del rango (inclusive).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "end",
            "in": "query",
            "required": true,
            "description": "Fin del rango (inclusive).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Ausencias aprobadas que solapan con el rango.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Absence"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organizacion inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Rango invalido (`end` < `start`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/hr/timesheets/week": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organizacion.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getTimesheetWeek",
        "x-tool": {
          "name": "get_timesheet_week",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "hr"
        ],
        "summary": "Vista semanal de timesheet",
        "description": "Devuelve el estado del periodo de aprobacion y los time entries del usuario agrupados por dia para la semana indicada. Por defecto el usuario autenticado; admin+ puede pasar `user_id` para ver el de otro usuario. `week_start` se normaliza al primer dia de esa semana segun `week_start_day` de la organizacion (`0` lunes, `5` sabado, `6` domingo): una fecha desplazada NO es un error de lectura. La respuesta devuelve el `week_start` ya normalizado.",
        "parameters": [
          {
            "name": "week_start",
            "in": "query",
            "required": true,
            "description": "Cualquier fecha de la semana (YYYY-MM-DD); se normaliza al primer dia de esa semana segun la organizacion.",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "user_id",
            "in": "query",
            "required": false,
            "description": "UUID del usuario (admin+ puede pasar otro usuario).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Vista semanal con periodo y dias.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TimesheetWeek"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Solo admin+ puede ver el timesheet de otro usuario.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organizacion inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`week_start` no es una fecha valida (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/hr/timesheets/submit": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organizacion.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "submitTimesheet",
        "x-tool": {
          "name": "submit_timesheet",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "hr"
        ],
        "summary": "Enviar timesheet para aprobacion",
        "description": "Crea o actualiza el periodo de la semana al estado `submitted`. `week_start` debe ser el PRIMER dia de la semana segun `week_start_day` de la organizacion (`0` lunes, `5` sabado, `6` domingo): es la invariante de la clave unica (org, usuario, semana), y con un ancla equivocada las horas se imputarian al periodo de nomina de al lado. El autor es siempre el usuario autenticado. Requiere member+.\n\nAvisa a los aprobadores (owner/admin de la organizacion, menos quien envia) y manda SIEMPRE un acuse a quien envia. La respuesta lleva `notified_approvers` para que la pantalla pueda decir a cuantos ha salido: hasta el 2026-08-31 el unico aviso era el de los aprobadores, asi que en una organizacion de una sola persona no se mandaba nada a nadie y quien enviaba no tenia forma de distinguir un envio correcto de uno tragado.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "week_start"
                ],
                "properties": {
                  "week_start": {
                    "type": "string",
                    "format": "date",
                    "description": "Primer dia de la semana a enviar, segun `week_start_day` de la organizacion."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Periodo actualizado a `submitted`, con el numero de aprobadores avisados.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TimesheetSubmitResult"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`) o el plan de la organizacion no incluye el modulo de RRHH (`feature_not_in_plan`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organizacion inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`week_start` no es el primer dia de la semana de la organizacion (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/hr/timesheets": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organizacion.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listTimesheets",
        "x-tool": {
          "name": "list_timesheets",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "hr"
        ],
        "summary": "Listar periodos de timesheet",
        "description": "Lista periodos de timesheet con total de minutos por semana. Admin+ ve todos; member solo los suyos. Filtrable por `status`, `user_id` y rango de fechas de `week_start`.",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Estado: draft, submitted, approved, rejected.",
            "schema": {
              "type": "string",
              "enum": [
                "draft",
                "submitted",
                "approved",
                "rejected"
              ]
            }
          },
          {
            "name": "user_id",
            "in": "query",
            "required": false,
            "description": "Filtra por usuario (admin+).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "week_start >= fecha.",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "week_start <= fecha.",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Máximo de elementos a devolver (1..200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de periodos (puede ser vacia).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/TimesheetPeriod"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organizacion inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/hr/timesheets/overview": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organizacion.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listTimesheetOverview",
        "tags": [
          "hr"
        ],
        "summary": "Vista de aprobaciones — todas las horas imputadas del equipo",
        "description": "Vista de aprobaciones: TODAS las (usuario, semana) con horas imputadas, con su estado de aprobacion — incluidas las semanas que NUNCA se enviaron para aprobacion (`not_submitted`), invisibles para `listTimesheets`. Es ADITIVA (solo lectura): no cambia el flujo de aprobar/rechazar, que sigue operando sobre `period_id`. Alcance por rol como el listado de horas (`resolve_list_user_scope`): admin/owner y manager+ ven todo el equipo (o el `user_id` pedido); un member solo lo suyo. Todo filtrado por la organizacion (multi-tenant).",
        "parameters": [
          {
            "name": "user_id",
            "in": "query",
            "required": false,
            "description": "Filtra por usuario (manager+; un member solo puede pedir el suyo).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "week_start >= fecha (primer dia de semana, inclusive).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "week_start <= fecha (primer dia de semana, inclusive).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Maximo de filas a devolver (1..200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Filas (usuario, semana) con horas imputadas y su estado.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/TimesheetOverviewRow"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Un member pidio el `user_id` de otro usuario (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organizacion inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parametro de query invalido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/hr/timesheets/{period_id}/approve": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organizacion.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "period_id",
          "in": "path",
          "required": true,
          "description": "UUID del periodo de timesheet.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "approveTimesheet",
        "x-tool": {
          "name": "approve_timesheet",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "hr"
        ],
        "summary": "Aprobar timesheet",
        "description": "Cambia el estado del periodo a `approved`. Solo `submitted` puede aprobarse (422 si no). Notifica al usuario. Requiere admin+.",
        "responses": {
          "200": {
            "description": "Periodo aprobado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TimesheetPeriod"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Periodo u organizacion inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "El periodo no esta en estado `submitted` (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/hr/timesheets/{period_id}/reject": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organizacion.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "period_id",
          "in": "path",
          "required": true,
          "description": "UUID del periodo de timesheet.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "rejectTimesheet",
        "x-tool": {
          "name": "reject_timesheet",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "hr"
        ],
        "summary": "Rechazar timesheet",
        "description": "Cambia el estado del periodo a `rejected`. Solo `submitted` o `approved` pueden rechazarse (422 si no). Notifica al usuario. Requiere admin+.",
        "responses": {
          "200": {
            "description": "Periodo rechazado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TimesheetPeriod"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Periodo u organizacion inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "El periodo no puede rechazarse en su estado actual.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/payroll/runs": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listPayrollRuns",
        "x-tool": {
          "name": "list_payroll_runs",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "payroll"
        ],
        "summary": "Listar corridas de nóminas",
        "description": "Lista las corridas de nóminas de la organización ordenadas por periodo descendente. Requiere admin+.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de corridas (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PayrollRun"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente; requiere admin+. Leer una nómina es admin+ desde el 2026-09-10: antes era member+ y cualquier persona de la organización podía leer el bruto, el IRPF y el neto de toda la plantilla.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createPayrollRun",
        "x-tool": {
          "name": "create_payroll_run",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "payroll"
        ],
        "summary": "Crear corrida de nóminas",
        "description": "Crea una corrida de nóminas en estado `draft` para el periodo indicado. Devuelve 409 si ya existe una corrida para ese periodo. Requiere admin+. Solo para organizaciones con `country = ES`: en cualquier otro país devuelve 422 con `payroll_country_not_supported` (no se crea un borrador que nunca se va a poder procesar).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "period"
                ],
                "properties": {
                  "period": {
                    "type": "string",
                    "description": "Periodo en formato YYYY-MM (e.g. '2026-07').",
                    "examples": [
                      "2026-07"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Corrida creada en estado `draft`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PayrollRun"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente; requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ya existe una corrida para ese periodo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Periodo inválido (`validation_error`), o la organización no es española y su nómina no se sabe calcular (`payroll_country_not_supported`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/payroll/runs/{run_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "run_id",
          "in": "path",
          "required": true,
          "description": "UUID de la corrida de nóminas.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getPayrollRun",
        "x-tool": {
          "name": "get_payroll_run",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "payroll"
        ],
        "summary": "Detalle de corrida con payslips",
        "description": "Devuelve la corrida de nóminas con todos sus payslips incluidos. Requiere admin+.",
        "responses": {
          "200": {
            "description": "Corrida con payslips.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PayrollRunWithPayslips"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente; requiere admin+. Leer una nómina es admin+ desde el 2026-09-10: antes era member+ y cualquier persona de la organización podía leer el bruto, el IRPF y el neto de toda la plantilla.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Corrida u organización inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deletePayrollRun",
        "x-tool": {
          "name": "delete_payroll_run",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "payroll"
        ],
        "summary": "Eliminar corrida de nóminas",
        "description": "Elimina una corrida en estado `draft` y sus payslips asociados. Las corridas `processed` no pueden borrarse (422). Requiere admin+.",
        "responses": {
          "204": {
            "description": "Corrida eliminada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente; requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Corrida u organización inexistente.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "La corrida no está en estado `draft`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/payroll/runs/{run_id}/process": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "run_id",
          "in": "path",
          "required": true,
          "description": "UUID de la corrida de nóminas.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "processPayrollRun",
        "x-tool": {
          "name": "process_payroll_run",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "payroll"
        ],
        "summary": "Procesar corrida de nóminas",
        "description": "Genera un payslip por cada empleado activo con `gross_annual_salary` configurado; los empleados sin salario se omiten. La corrida pasa a estado `processed`. Si ya estaba procesada devuelve 422. Requiere admin+. El cálculo (cuota obrera de la Seguridad Social española + IRPF) solo está implementado para `country = ES`: en cualquier otro país devuelve 422 con `payroll_country_not_supported` y no se genera ni un payslip. El gasto de nómina que se crea en Finanzas se emite en la divisa BASE de la organización (`default_currency`), no en euros por decreto.",
        "responses": {
          "200": {
            "description": "Corrida procesada con los payslips generados.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PayrollRunWithPayslips"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente; requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Corrida u organización inexistente.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "La corrida ya ha sido procesada (`validation_error`), o la organización no es española y su nómina no se sabe calcular (`payroll_country_not_supported`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/payroll/runs/{run_id}/payslips": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "run_id",
          "in": "path",
          "required": true,
          "description": "UUID de la corrida de nóminas.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listPayslips",
        "x-tool": {
          "name": "list_payslips",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "payroll"
        ],
        "summary": "Listar payslips de una corrida",
        "description": "Lista los payslips individuales de la corrida indicada. Requiere admin+.",
        "responses": {
          "200": {
            "description": "Lista de payslips.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Payslip"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente; requiere admin+. Leer una nómina es admin+ desde el 2026-09-10: antes era member+ y cualquier persona de la organización podía leer el bruto, el IRPF y el neto de toda la plantilla.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Corrida u organización inexistente.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/payroll/payslips/{payslip_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "payslip_id",
          "in": "path",
          "required": true,
          "description": "UUID del payslip.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getPayslip",
        "x-tool": {
          "name": "get_payslip",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "payroll"
        ],
        "summary": "Detalle de payslip individual",
        "description": "Devuelve un payslip individual. Requiere admin+.",
        "responses": {
          "200": {
            "description": "Payslip.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Payslip"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente; requiere admin+. Leer una nómina es admin+ desde el 2026-09-10: antes era member+ y cualquier persona de la organización podía leer el bruto, el IRPF y el neto de toda la plantilla.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Payslip u organización inexistente.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/hr/schedules": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "schedulesOverview",
        "x-tool": {
          "name": "schedules_overview",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "payroll"
        ],
        "summary": "Vista org-wide de horarios semanales",
        "description": "Devuelve los horarios de todos los empleados de la organización agrupados por empleado, con el total de horas semanales. Filtrable por `weekday`. Requiere member+.",
        "parameters": [
          {
            "name": "weekday",
            "in": "query",
            "required": false,
            "description": "Filtra por día (0=Lunes … 6=Domingo).",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "maximum": 6
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de empleados con sus horarios.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/SchedulesOverviewItem"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "El plan de la organizacion no incluye el modulo de nominas (`feature_not_in_plan`). El ROL no da 403 aqui —esta lectura es member+—, pero la puerta empieza por comprobar el plan, asi que una organizacion sin `payroll` contratado recibe 403 y no 404. Declarado a mano el 2026-09-10: lo levanta la policy, no una firma, y el check de conformidad no lo deduce solo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/hr/schedules/employees/{employee_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "employee_id",
          "in": "path",
          "required": true,
          "description": "UUID del empleado.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listWorkSchedule",
        "x-tool": {
          "name": "list_work_schedule",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "payroll"
        ],
        "summary": "Horario semanal de un empleado",
        "description": "Devuelve los turnos del empleado con el total de horas semanales. Requiere member+.",
        "responses": {
          "200": {
            "description": "Horario del empleado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WorkScheduleEmployeeSummary"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "El plan de la organizacion no incluye el modulo de nominas (`feature_not_in_plan`). El ROL no da 403 aqui —esta lectura es member+—, pero la puerta empieza por comprobar el plan, asi que una organizacion sin `payroll` contratado recibe 403 y no 404. Declarado a mano el 2026-09-10: lo levanta la policy, no una firma, y el check de conformidad no lo deduce solo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Empleado u organización inexistente.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "put": {
        "operationId": "setWorkSchedule",
        "x-tool": {
          "name": "set_work_schedule",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "payroll"
        ],
        "summary": "Reemplazar horario semanal (bulk-set)",
        "description": "Reemplaza el horario completo del empleado con los turnos indicados. Una lista vacía borra todos los turnos. Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "entries": {
                    "type": "array",
                    "maxItems": 50,
                    "items": {
                      "type": "object",
                      "required": [
                        "weekday",
                        "start_time",
                        "end_time"
                      ],
                      "properties": {
                        "weekday": {
                          "type": "integer",
                          "minimum": 0,
                          "maximum": 6
                        },
                        "start_time": {
                          "type": "string",
                          "description": "HH:MM (24 h)"
                        },
                        "end_time": {
                          "type": "string",
                          "description": "HH:MM (24 h)"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Horario reemplazado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WorkScheduleEmployeeSummary"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente; requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Empleado u organización inexistente.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/hr/schedules/employees/{employee_id}/entries": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "employee_id",
          "in": "path",
          "required": true,
          "description": "UUID del empleado.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "addScheduleEntry",
        "x-tool": {
          "name": "add_schedule_entry",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "payroll"
        ],
        "summary": "Añadir turno al horario de un empleado",
        "description": "Añade un turno individual al horario del empleado. Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "weekday",
                  "start_time",
                  "end_time"
                ],
                "properties": {
                  "weekday": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 6
                  },
                  "start_time": {
                    "type": "string",
                    "description": "HH:MM (24 h)"
                  },
                  "end_time": {
                    "type": "string",
                    "description": "HH:MM (24 h)"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Turno añadido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WorkSchedule"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente; requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Empleado u organización inexistente.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/hr/schedules/entries/{entry_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "entry_id",
          "in": "path",
          "required": true,
          "description": "UUID del turno.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "removeScheduleEntry",
        "x-tool": {
          "name": "remove_schedule_entry",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "payroll"
        ],
        "summary": "Eliminar turno del horario",
        "description": "Elimina un turno individual del horario de un empleado. Requiere admin+.",
        "responses": {
          "204": {
            "description": "Turno eliminado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente; requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Turno u organización inexistente.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/workforce/capacity": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "week_start",
          "in": "query",
          "required": true,
          "description": "Cualquier fecha de la semana (YYYY-MM-DD); se normaliza al primer día de esa semana según `week_start_day` de la organización (`0` lunes, `5` sábado, `6` domingo), no a un lunes fijo.",
          "schema": {
            "type": "string",
            "format": "date"
          }
        }
      ],
      "get": {
        "operationId": "workforceCapacity",
        "x-tool": {
          "name": "workforce_capacity",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "workforce"
        ],
        "summary": "Capacidad del equipo (semana)",
        "description": "Conciliación de capacidad de la organización para la semana de `week_start`. Por cada miembro: horas esperadas (modelo híbrido: override manual → horario del empleado → horario del departamento), festivos y ausencias que descuentan, capacidad neta, horas imputadas y delta (imputado − neto). Requiere manager+. El coste/tarifa NO se expone aquí, solo horas.",
        "responses": {
          "200": {
            "description": "Conciliación por miembro (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Capacity"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetro inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/workforce/schedule": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "workforceGetSchedule",
        "x-tool": {
          "name": "get_workforce_schedule",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "workforce"
        ],
        "summary": "Horario por defecto de la organización",
        "description": "Devuelve el horario laboral por defecto de la organización, base (nivel más general) del modelo híbrido de capacidad. Lo puede leer cualquier miembro de la organización.",
        "responses": {
          "200": {
            "description": "Horario por defecto (puede ser `null`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OrgSchedule"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "put": {
        "operationId": "workforceSetSchedule",
        "x-tool": {
          "name": "set_workforce_schedule",
          "domain": "hr",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "workforce"
        ],
        "summary": "Fijar el horario por defecto de la organización",
        "description": "Reemplaza el horario laboral por defecto de la organización. Requiere manager+. `weekly_schedule: null` borra el horario por defecto.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OrgScheduleUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Horario por defecto actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OrgSchedule"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Horario inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/gantt": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "projectGantt",
        "x-tool": {
          "name": "project_gantt",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "roadmap"
        ],
        "summary": "Gantt del proyecto",
        "description": "Devuelve las barras Gantt de todas las tareas del proyecto, incluyendo dependencias. Las tareas sin fechas se devuelven con `start_date`/ `due_date` null para que el cliente las coloque en una banda \"sin planificar\". Requiere ser miembro de la organización.",
        "responses": {
          "200": {
            "description": "Payload Gantt completo del proyecto.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GanttResponse"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización o proyecto inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/roadmap": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "orgRoadmap",
        "x-tool": {
          "name": "org_roadmap",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "roadmap"
        ],
        "summary": "Timeline de proyectos (roadmap org)",
        "description": "Proyectos activos de la organización VISIBLES para quien llama, con su rango temporal y su progreso. No son «todos»: los `visibility: restricted` que el caller no puede ver quedan fuera, con el mismo criterio que `GET /projects` (owner/admin ven todo; el resto, los de organización, los suyos por membresía explícita y los que les abre su departamento). `start` es la fecha más temprana de las tareas del proyecto y `end` la más tardía, contando tanto las de inicio como las de vencimiento; un proyecto sin NINGUNA tarea fechada trae las dos a `null` —no se inventa una fecha de inicio—. Ordenado por start (nulls al final). Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Lista de items del roadmap (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/RoadmapItem"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/evm": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "as_of",
          "in": "query",
          "required": false,
          "description": "Fecha de referencia para el cálculo EVM (ISO 8601 YYYY-MM-DD). Por defecto, la fecha de hoy en servidor.",
          "schema": {
            "type": "string",
            "format": "date"
          }
        }
      ],
      "get": {
        "operationId": "projectEvm",
        "x-tool": {
          "name": "project_evm",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "roadmap"
        ],
        "summary": "Resumen EVM del proyecto (esfuerzo-horas)",
        "description": "Devuelve el resumen de Earned Value Management del proyecto calculado en horas de esfuerzo (sin tarifa monetaria). Incluye BAC, EV, AC, PV, CPI, SPI, EAC, ETC y VAC. CPI y SPI son `null` cuando su denominador es cero (nunca produce error 500). Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Resumen EVM.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EvmSummary"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización o proyecto inexistente, o usuario no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetro `as_of` inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/tasks/{task_id}/attachments": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "description": "UUID de la tarea.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listTaskAttachments",
        "x-tool": {
          "name": "list_task_attachments",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Listar adjuntos de la tarea",
        "description": "Devuelve los metadatos de los adjuntos de la tarea (sin bytes). Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Lista de adjuntos (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/TaskAttachment"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Tarea, proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "uploadTaskAttachment",
        "tags": [
          "tasks"
        ],
        "summary": "Subir adjunto",
        "description": "Sube un fichero como adjunto de la tarea. Almacenado en DB (no filesystem). Límite 10 MB. Requiere ser miembro con permisos de escritura en tareas.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "Fichero a subir (máximo 10 MB)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Adjunto creado; devuelve metadatos (sin bytes).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaskAttachment"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Tarea, proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "Fichero demasiado grande (supera 10 MB).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/tasks/{task_id}/attachments/{attachment_id}/download": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "description": "UUID de la tarea.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "attachment_id",
          "in": "path",
          "required": true,
          "description": "UUID del adjunto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "downloadTaskAttachment",
        "tags": [
          "tasks"
        ],
        "summary": "Descargar adjunto",
        "description": "Devuelve el contenido binario del adjunto con los headers `Content-Disposition` y `Content-Type` correctos. Requiere ser miembro.",
        "responses": {
          "200": {
            "description": "Bytes del fichero.",
            "content": {
              "application/octet-stream": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Adjunto, tarea, proyecto u organización no encontrado (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteTaskAttachment",
        "tags": [
          "tasks"
        ],
        "summary": "Eliminar adjunto",
        "description": "Elimina el adjunto. Puede hacerlo el propio subidor o un admin+. Devuelve 204.",
        "responses": {
          "204": {
            "description": "Adjunto eliminado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "El usuario no es el subidor ni tiene rol admin+ (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Adjunto, tarea, proyecto u organización no encontrado (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/tasks/{task_id}/custom-fields": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "description": "UUID de la tarea.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getTaskCustomFields",
        "x-tool": {
          "name": "get_task_custom_fields",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Obtener campos personalizados de la tarea",
        "description": "Devuelve los valores de campos personalizados asignados a la tarea.",
        "responses": {
          "200": {
            "description": "Lista de valores (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CustomFieldValue"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Tarea, proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "put": {
        "operationId": "setTaskCustomFields",
        "x-tool": {
          "name": "set_task_custom_fields",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Fijar campos personalizados de la tarea",
        "description": "Bulk-upsert de valores de campos personalizados en la tarea. Valida el tipo de cada campo (422 si inválido). Devuelve todos los valores actualizados.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "fields"
                ],
                "properties": {
                  "fields": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "required": [
                        "def_id",
                        "value"
                      ],
                      "properties": {
                        "def_id": {
                          "type": "string",
                          "format": "uuid",
                          "description": "UUID de la definición del campo."
                        },
                        "value": {
                          "type": "string",
                          "description": "Valor como string; se valida contra `field_type`."
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Valores actualizados.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CustomFieldValue"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Tarea, proyecto u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Valor inválido para el tipo de campo, o def_id desconocido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/custom-field-defs": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listCustomFieldDefs",
        "x-tool": {
          "name": "list_custom_field_defs",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Listar definiciones de campos personalizados",
        "description": "Devuelve las definiciones de campos personalizados de la organización, ordenadas por `position`.",
        "responses": {
          "200": {
            "description": "Lista de definiciones (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CustomFieldDef"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createCustomFieldDef",
        "x-tool": {
          "name": "create_custom_field_def",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Crear definición de campo personalizado",
        "description": "Crea una nueva definición. Requiere rol `admin` o superior.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "field_type"
                ],
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "field_type": {
                    "type": "string",
                    "enum": [
                      "text",
                      "number",
                      "date",
                      "select",
                      "url",
                      "email"
                    ]
                  },
                  "options": {
                    "type": [
                      "array",
                      "null"
                    ],
                    "description": "Requerido para `field_type=select`.",
                    "items": {
                      "type": "string"
                    }
                  },
                  "position": {
                    "type": "integer",
                    "description": "Orden de visualización; por defecto 0."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Definición creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CustomFieldDef"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `admin` o `owner` (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/custom-field-defs/{def_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "def_id",
          "in": "path",
          "required": true,
          "description": "UUID de la definición.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "updateCustomFieldDef",
        "x-tool": {
          "name": "update_custom_field_def",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Actualizar definición de campo personalizado",
        "description": "Actualización parcial de la definición. Requiere rol `admin` o superior.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "options": {
                    "type": [
                      "array",
                      "null"
                    ],
                    "items": {
                      "type": "string"
                    }
                  },
                  "position": {
                    "type": "integer"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Definición actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CustomFieldDef"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `admin` o `owner` (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Definición u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteCustomFieldDef",
        "x-tool": {
          "name": "delete_custom_field_def",
          "domain": "pm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "tasks"
        ],
        "summary": "Eliminar definición de campo personalizado",
        "description": "Elimina la definición y en cascada todos sus valores. Requiere rol `admin` o superior.",
        "responses": {
          "204": {
            "description": "Definición eliminada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente — requiere `admin` o `owner` (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Definición u organización inexistente, o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/contracts": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listContracts",
        "x-tool": {
          "name": "list_contracts",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "contracts"
        ],
        "summary": "List contracts",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "draft",
                "active",
                "signed",
                "expired",
                "terminated",
                "cancelled"
              ]
            }
          },
          {
            "name": "contract_type",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "service",
                "employment",
                "nda",
                "framework",
                "lease",
                "other"
              ]
            }
          },
          {
            "name": "client_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 200,
              "minimum": 1,
              "maximum": 200
            }
          }
        ],
        "responses": {
          "200": {
            "description": "List of contracts.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Contract"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized."
          },
          "404": {
            "description": "Organization not found or not a member."
          },
          "422": {
            "description": "Invalid query filter."
          }
        }
      },
      "post": {
        "operationId": "createContract",
        "x-tool": {
          "name": "create_contract",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "contracts"
        ],
        "summary": "Create contract",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ContractCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Contract"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized."
          },
          "403": {
            "description": "Requires admin or owner."
          },
          "404": {
            "description": "Organization not found or not a member."
          },
          "422": {
            "description": "Validation error."
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/contracts/{contract_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "contract_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getContract",
        "x-tool": {
          "name": "get_contract",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "contracts"
        ],
        "summary": "Get contract",
        "responses": {
          "200": {
            "description": "Contract.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Contract"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized."
          },
          "404": {
            "description": "Not found."
          }
        }
      },
      "patch": {
        "operationId": "updateContract",
        "x-tool": {
          "name": "update_contract",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "contracts"
        ],
        "summary": "Update contract",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ContractUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Contract"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized."
          },
          "403": {
            "description": "Requires admin or owner."
          },
          "404": {
            "description": "Not found."
          },
          "422": {
            "description": "Validation error."
          }
        }
      },
      "delete": {
        "operationId": "deleteContract",
        "x-tool": {
          "name": "delete_contract",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "contracts"
        ],
        "summary": "Delete contract",
        "responses": {
          "204": {
            "description": "Deleted."
          },
          "401": {
            "description": "Unauthorized."
          },
          "403": {
            "description": "Requires admin or owner."
          },
          "404": {
            "description": "Not found."
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/contracts/from-template/{template_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "template_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "createContractFromTemplate",
        "x-tool": {
          "name": "create_contract_from_template",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "contracts"
        ],
        "summary": "Create contract from template",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ContractCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Contract"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized."
          },
          "403": {
            "description": "Requires admin or owner."
          },
          "404": {
            "description": "Template not found."
          },
          "422": {
            "description": "Validation error."
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/contract-templates": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listContractTemplates",
        "x-tool": {
          "name": "list_contract_templates",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "contracts"
        ],
        "summary": "List contract templates",
        "responses": {
          "200": {
            "description": "List of templates.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ContractTemplate"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized."
          },
          "404": {
            "description": "Org not found or not a member."
          }
        }
      },
      "post": {
        "operationId": "createContractTemplate",
        "tags": [
          "contracts"
        ],
        "summary": "Create contract template",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ContractTemplateCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ContractTemplate"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized."
          },
          "403": {
            "description": "Requires admin or owner."
          },
          "404": {
            "description": "Organization not found or not a member."
          },
          "422": {
            "description": "Validation error."
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/contract-templates/{template_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "template_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getContractTemplate",
        "tags": [
          "contracts"
        ],
        "summary": "Get contract template",
        "responses": {
          "200": {
            "description": "Template.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ContractTemplate"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized."
          },
          "404": {
            "description": "Not found."
          }
        }
      },
      "patch": {
        "operationId": "updateContractTemplate",
        "tags": [
          "contracts"
        ],
        "summary": "Update contract template",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ContractTemplateUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ContractTemplate"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized."
          },
          "403": {
            "description": "Requires admin or owner."
          },
          "404": {
            "description": "Not found."
          },
          "422": {
            "description": "Validation error."
          }
        }
      },
      "delete": {
        "operationId": "deleteContractTemplate",
        "tags": [
          "contracts"
        ],
        "summary": "Delete contract template",
        "responses": {
          "204": {
            "description": "Deleted."
          },
          "401": {
            "description": "Unauthorized."
          },
          "403": {
            "description": "Requires admin or owner."
          },
          "404": {
            "description": "Not found."
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/contracts/{contract_id}/milestones": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "contract_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listMilestones",
        "x-tool": {
          "name": "list_milestones",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "contracts"
        ],
        "summary": "List contract milestones",
        "responses": {
          "200": {
            "description": "List of milestones.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ContractMilestone"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized."
          },
          "404": {
            "description": "Not found."
          }
        }
      },
      "post": {
        "operationId": "createMilestone",
        "x-tool": {
          "name": "create_milestone",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "contracts"
        ],
        "summary": "Create milestone",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ContractMilestoneCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ContractMilestone"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized."
          },
          "403": {
            "description": "Requires admin or owner."
          },
          "404": {
            "description": "Contract not found."
          },
          "422": {
            "description": "Validation error."
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/contracts/{contract_id}/milestones/{milestone_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "contract_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "milestone_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "updateMilestone",
        "x-tool": {
          "name": "update_milestone",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "contracts"
        ],
        "summary": "Update milestone",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ContractMilestoneUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ContractMilestone"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized."
          },
          "403": {
            "description": "Requires admin or owner."
          },
          "404": {
            "description": "Not found."
          },
          "422": {
            "description": "Validation error."
          }
        }
      },
      "delete": {
        "operationId": "deleteMilestone",
        "x-tool": {
          "name": "delete_milestone",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "contracts"
        ],
        "summary": "Delete milestone",
        "responses": {
          "204": {
            "description": "Deleted."
          },
          "401": {
            "description": "Unauthorized."
          },
          "403": {
            "description": "Requires admin or owner."
          },
          "404": {
            "description": "Not found."
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/bi/dashboards": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listBiDashboards",
        "x-tool": {
          "name": "list_bi_dashboards",
          "domain": "bi",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "bi"
        ],
        "summary": "List BI dashboards",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 200,
              "minimum": 1,
              "maximum": 200
            }
          }
        ],
        "responses": {
          "200": {
            "description": "List of BI dashboards.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/BiDashboard"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized."
          },
          "404": {
            "description": "Org not found or not a member."
          }
        }
      },
      "post": {
        "operationId": "createBiDashboard",
        "x-tool": {
          "name": "create_bi_dashboard",
          "domain": "bi",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "bi"
        ],
        "summary": "Create BI dashboard",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BiDashboardCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BiDashboard"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized."
          },
          "403": {
            "description": "Requires admin or owner."
          },
          "404": {
            "description": "Org not found or not a member."
          },
          "422": {
            "description": "Invalid body."
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/bi/dashboards/{dashboard_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "dashboard_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getBiDashboard",
        "x-tool": {
          "name": "get_bi_dashboard",
          "domain": "bi",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "bi"
        ],
        "summary": "Get BI dashboard",
        "responses": {
          "200": {
            "description": "BI dashboard.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BiDashboard"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized."
          },
          "404": {
            "description": "Not found."
          }
        }
      },
      "patch": {
        "operationId": "updateBiDashboard",
        "tags": [
          "bi"
        ],
        "summary": "Update BI dashboard",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BiDashboardUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BiDashboard"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized."
          },
          "403": {
            "description": "Requires admin or owner."
          },
          "404": {
            "description": "Not found."
          },
          "422": {
            "description": "Invalid body."
          }
        }
      },
      "delete": {
        "operationId": "deleteBiDashboard",
        "tags": [
          "bi"
        ],
        "summary": "Delete BI dashboard",
        "responses": {
          "204": {
            "description": "Deleted."
          },
          "401": {
            "description": "Unauthorized."
          },
          "403": {
            "description": "Requires admin or owner."
          },
          "404": {
            "description": "Not found."
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/bi/dashboards/{dashboard_id}/widgets": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "dashboard_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listBiWidgets",
        "x-tool": {
          "name": "list_bi_widgets",
          "domain": "bi",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "bi"
        ],
        "summary": "List widgets in a dashboard",
        "responses": {
          "200": {
            "description": "List of widgets.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/BiWidget"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized."
          },
          "404": {
            "description": "Dashboard not found."
          }
        }
      },
      "post": {
        "operationId": "createBiWidget",
        "tags": [
          "bi"
        ],
        "summary": "Create widget",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BiWidgetCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BiWidget"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized."
          },
          "403": {
            "description": "Requires admin or owner."
          },
          "404": {
            "description": "Dashboard not found."
          },
          "422": {
            "description": "Invalid body."
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/bi/dashboards/{dashboard_id}/widgets/{widget_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "dashboard_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "widget_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "updateBiWidget",
        "tags": [
          "bi"
        ],
        "summary": "Update widget",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BiWidgetUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BiWidget"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized."
          },
          "403": {
            "description": "Requires admin or owner."
          },
          "404": {
            "description": "Not found."
          },
          "422": {
            "description": "Invalid body."
          }
        }
      },
      "delete": {
        "operationId": "deleteBiWidget",
        "tags": [
          "bi"
        ],
        "summary": "Delete widget",
        "responses": {
          "204": {
            "description": "Deleted."
          },
          "401": {
            "description": "Unauthorized."
          },
          "403": {
            "description": "Requires admin or owner."
          },
          "404": {
            "description": "Not found."
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/bi/widgets/{widget_id}/data": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "widget_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "biWidgetData",
        "x-tool": {
          "name": "bi_widget_data",
          "domain": "bi",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "bi"
        ],
        "summary": "Run widget query and return data",
        "description": "Executes the whitelist-driven aggregate defined by the widget and returns rows. All dimension/measure/source names are resolved against the server whitelist — unknown names produce 422. Result is capped at 100 rows.",
        "responses": {
          "200": {
            "description": "Query result rows.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BiWidgetData"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized."
          },
          "403": {
            "description": "Requires member or higher."
          },
          "404": {
            "description": "Widget not found."
          },
          "422": {
            "description": "Unknown data_source / dimension / measure_field."
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/bi/query": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "biAdHocQuery",
        "x-tool": {
          "name": "bi_ad_hoc_query",
          "domain": "bi",
          "profile": "all",
          "sensitive": true,
          "muta": false
        },
        "tags": [
          "bi"
        ],
        "summary": "Run ad-hoc BI query (without saving a widget)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BiAdHocQuery"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Query result rows.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BiWidgetData"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized."
          },
          "403": {
            "description": "Requires member or higher."
          },
          "404": {
            "description": "Org not found or not a member."
          },
          "422": {
            "description": "Unknown data_source / dimension / measure_field."
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/shared-projects/{project_id}/tasks/{task_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto ajeno (host).",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "description": "UUID de la tarea a actualizar.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getSharedTask",
        "x-tool": {
          "name": "get_shared_task",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Leer una tarea de un proyecto compartido (solo lectura)",
        "description": "Detalle (solo lectura) de una tarea de un proyecto compartido con esta organización. Mismo shape que `Task`. member+. `404` si el proyecto no está compartido / enlace no aceptado / la tarea no pertenece al proyecto.",
        "responses": {
          "200": {
            "description": "Detalle de la tarea compartida.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Task"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No compartido / tarea no encontrada (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateSharedTask",
        "x-tool": {
          "name": "update_shared_task",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Actualizar tarea en proyecto compartido (cross-tenant)",
        "description": "PATCH parcial de una tarea en el proyecto host. Solo permite modificar `status`, `priority`, `assignee_id`, `title` y `description`. `assignee_id` se VALIDA contra el host (miembro o colaborador externo activo; `null` desasigna) → `422` si no. Requiere permiso `full` en la compartición y enlace aceptado. member+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SharedTaskUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Tarea actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Task"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Permiso insuficiente o enlace no aceptado (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto o tarea no encontrados (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteSharedTask",
        "x-tool": {
          "name": "delete_shared_task",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Borrar tarea en proyecto compartido (cross-tenant)",
        "description": "Borra una tarea del proyecto host. Requiere permiso `full` en la compartición y enlace aceptado (el host concede escritura completa de tareas y puede revocar el comparto para cortarla). member+. `404` si la tarea no pertenece al proyecto compartido.",
        "responses": {
          "204": {
            "description": "Tarea borrada."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Permiso insuficiente o enlace no aceptado (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto o tarea no encontrados (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/shared-projects/{project_id}/tasks/{task_id}/comments": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto ajeno (host).",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "description": "UUID de la tarea.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listSharedTaskComments",
        "x-tool": {
          "name": "list_shared_task_comments",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Comentarios de una tarea de un proyecto compartido (solo lectura)",
        "description": "Lista (solo lectura) los comentarios de una tarea del proyecto host. Mismo shape que `TaskComment`. member+ con cualquier permiso de compartición. `404` si no está compartido / enlace no aceptado / la tarea no pertenece al proyecto.\n\nPaginable con `limit`/`offset` (retrocompatible: sin parámetros devuelve como mucho 200 comentarios).",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Comentarios de la tarea (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/TaskComment"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No compartido / tarea no encontrada (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createSharedTaskComment",
        "x-tool": {
          "name": "create_shared_task_comment",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Comentar tarea en proyecto compartido (cross-tenant)",
        "description": "Añade un comentario a una tarea del proyecto host. El autor es el usuario de la organización llamante. Requiere permiso `read_comment` o `full`. member+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "body"
                ],
                "properties": {
                  "body": {
                    "type": "string",
                    "description": "Texto del comentario (markdown)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Comentario creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaskComment"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Permiso insuficiente o enlace no aceptado (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto o tarea no encontrados (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/shared-projects/{project_id}/tasks/{task_id}/subtasks": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto ajeno (host).",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "description": "UUID de la tarea padre.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listSharedTaskSubtasks",
        "x-tool": {
          "name": "list_shared_task_subtasks",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Subtareas de una tarea de un proyecto compartido (solo lectura)",
        "description": "Hijas directas de una tarea del proyecto host. Mismo shape que `Task`. member+. `404` si no está compartido / enlace no aceptado / la tarea no pertenece al proyecto.\n\nPaginable con `limit`/`offset` (retrocompatible: sin parámetros devuelve como mucho 200 subtareas).",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Subtareas (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Task"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No compartido / tarea no encontrada (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/shared-projects/{project_id}/board-columns": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto ajeno (host).",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listSharedProjectBoardColumns",
        "x-tool": {
          "name": "list_shared_project_board_columns",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Columnas del tablero de un proyecto compartido (solo lectura)",
        "description": "Columnas del tablero Kanban del proyecto host. Necesarias para conocer los `status_key` válidos antes de mover una tarea. Mismo shape que `BoardColumn`. member+. `404` si no está compartido / enlace no aceptado.",
        "responses": {
          "200": {
            "description": "Columnas del tablero.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/BoardColumn"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No compartido con esta organización (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/shared-projects/{project_id}/sprints": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto ajeno (host).",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listSharedProjectSprints",
        "x-tool": {
          "name": "list_shared_project_sprints",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Sprints de un proyecto compartido (solo lectura)",
        "description": "Sprints del proyecto host. Mismo shape que `Sprint`. member+. `404` si no está compartido / enlace no aceptado.",
        "responses": {
          "200": {
            "description": "Sprints (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Sprint"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No compartido con esta organización (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/shared-projects/{project_id}/tasks/{task_id}/attachments": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto ajeno (host).",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "description": "UUID de la tarea.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listSharedTaskAttachments",
        "x-tool": {
          "name": "list_shared_task_attachments",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Adjuntos de una tarea de un proyecto compartido (solo lectura)",
        "description": "Metadatos (solo lectura) de los adjuntos de una tarea del proyecto host. Mismo shape que `TaskAttachment`. member+. `404` si no está compartido / enlace no aceptado / la tarea no pertenece al proyecto.",
        "responses": {
          "200": {
            "description": "Adjuntos de la tarea (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/TaskAttachment"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No compartido / tarea no encontrada (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/shared-projects/{project_id}/tasks/{task_id}/attachments/{attachment_id}/download": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto ajeno (host).",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "description": "UUID de la tarea.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "attachment_id",
          "in": "path",
          "required": true,
          "description": "UUID del adjunto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "downloadSharedTaskAttachment",
        "tags": [
          "crossorg"
        ],
        "summary": "Descargar un adjunto de una tarea compartida (solo lectura)",
        "description": "Bytes crudos de un adjunto de una tarea del proyecto host. member+. `404` si no está compartido / enlace no aceptado / el adjunto no pertenece a la tarea.",
        "responses": {
          "200": {
            "description": "Bytes del adjunto.",
            "content": {
              "application/octet-stream": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No compartido / adjunto no encontrado (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/shared-projects/{project_id}/time-entries": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto ajeno (host).",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listSharedProjectTimeEntries",
        "x-tool": {
          "name": "list_shared_project_time_entries",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Imputaciones de tiempo de un proyecto compartido (solo lectura)",
        "description": "Imputaciones de tiempo (solo lectura) del proyecto host. Mismo shape que `TimeEntry`. Filtro opcional por `task_id`. member+. `404` si no está compartido / enlace no aceptado.\n\nPaginable con `limit`/`offset` (retrocompatible: sin parámetros devuelve como mucho 200 imputaciones).",
        "parameters": [
          {
            "name": "task_id",
            "in": "query",
            "required": false,
            "description": "Filtra por tarea (UUID).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Imputaciones (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/TimeEntry"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No compartido con esta organización (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createSharedTimeEntry",
        "x-tool": {
          "name": "create_shared_time_entry",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Imputar tiempo en un proyecto compartido (cross-tenant)",
        "description": "Imputa tiempo en el proyecto host. La imputación queda en el tenant host y se atribuye al usuario llamante (guest). `task_id` opcional, validado al mismo proyecto. Requiere permiso `full` y enlace aceptado. member+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "minutes",
                  "entry_date"
                ],
                "properties": {
                  "minutes": {
                    "type": "integer",
                    "minimum": 1,
                    "description": "Minutos imputados (> 0)."
                  },
                  "entry_date": {
                    "type": "string",
                    "format": "date",
                    "description": "Fecha de imputación (YYYY-MM-DD)."
                  },
                  "task_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "Tarea del proyecto a la que imputar (opcional)."
                  },
                  "description": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Nota de la imputación."
                  },
                  "is_billable": {
                    "type": [
                      "boolean",
                      "null"
                    ],
                    "description": "Facturable al cliente (null → true)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Imputación creada en el tenant host.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TimeEntry"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Permiso insuficiente (`read`/`read_comment` share) (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto no compartido con esta organización (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/external-collaborators": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto (debe pertenecer a esta organización).",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listExternalCollaborators",
        "x-tool": {
          "name": "list_external_collaborators",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Listar colaboradores externos de un proyecto",
        "description": "Usuarios de otras organizaciones con acceso colaborativo al proyecto. member+. Solo visible desde el lado dueño.",
        "responses": {
          "200": {
            "description": "Colaboradores externos (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ExternalCollaborator"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto/organización inexistente o no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "addExternalCollaborator",
        "x-tool": {
          "name": "add_external_collaborator",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Añadir colaborador externo a un proyecto",
        "description": "Invita a un usuario de otra organización como colaborador del proyecto. Requiere admin+, que exista un `OrgLink` aceptado con la org del usuario, y que el usuario sea miembro de esa org. `409` si el usuario ya es colaborador activo. `422` si no hay enlace aceptado.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "user_id",
                  "user_org_id"
                ],
                "properties": {
                  "user_id": {
                    "type": "string",
                    "format": "uuid",
                    "description": "UUID del usuario a invitar."
                  },
                  "user_org_id": {
                    "type": "string",
                    "format": "uuid",
                    "description": "UUID de la organización del usuario."
                  },
                  "role": {
                    "type": "string",
                    "enum": [
                      "viewer",
                      "commenter",
                      "approver"
                    ],
                    "default": "viewer",
                    "description": "Rol asignado al colaborador."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Colaborador añadido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ExternalCollaborator"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); añadir requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto/organización/usuario inexistente o no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "El usuario ya es colaborador activo de este proyecto (`conflict`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Sin enlace aceptado, usuario no en la org indicada, o misma org (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/external-collaborators/{collab_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "collab_id",
          "in": "path",
          "required": true,
          "description": "UUID del colaborador externo.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "removeExternalCollaborator",
        "x-tool": {
          "name": "remove_external_collaborator",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Revocar acceso de un colaborador externo",
        "description": "Revoca el acceso colaborativo (establece `revoked_at`). Requiere admin+. El proyecto debe pertenecer a esta organización.",
        "responses": {
          "204": {
            "description": "Acceso revocado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); revocar requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Colaborador/proyecto inexistente o ajeno a esta organización (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/clients/{client_id}/link-org": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "client_id",
          "in": "path",
          "required": true,
          "description": "UUID del cliente CRM.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "linkClientOrg",
        "tags": [
          "crossorg"
        ],
        "summary": "Vincular cliente CRM a una organización",
        "description": "Establece `clients.linked_org_id` apuntando a una organización Projekt real. Requiere admin+, y que se cumpla al menos UNA de las condiciones: (a) exista un `OrgLink` aceptado con la org destino, o (b) el usuario actor sea admin u owner de la org destino (caso típico: usuario que opera en varias organizaciones propias). `422` si ninguna condición se cumple o el destino es la misma org. `409` si otra fila de cliente ya apunta a esa org.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "target_org_id"
                ],
                "properties": {
                  "target_org_id": {
                    "type": "string",
                    "format": "uuid",
                    "description": "UUID de la organización Projekt a vincular."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Vínculo establecido.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "client_id",
                    "linked_org_id"
                  ],
                  "properties": {
                    "client_id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "linked_org_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); vincular requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Cliente u organización inexistente, o no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Otra fila de cliente ya apunta a esa organización (`conflict`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Sin enlace aceptado ni rol admin/owner en org destino, mismo org, o cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "unlinkClientOrg",
        "tags": [
          "crossorg"
        ],
        "summary": "Desvincular cliente CRM de su organización",
        "description": "Elimina el puntero `linked_org_id` del cliente (lo pone a `null`). Requiere admin+. `422` si el cliente no tiene vínculo activo.",
        "responses": {
          "204": {
            "description": "Vínculo eliminado; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`); desvincular requiere admin+.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Cliente inexistente o no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "El cliente no tiene ningún vínculo activo (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/mywork/cross-org": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getCrossOrgMyWork",
        "x-tool": {
          "name": "get_crossorg_my_work",
          "domain": "crossorg",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "crossorg"
        ],
        "summary": "Tareas de proyectos compartidos asignadas al usuario",
        "description": "Tareas de proyectos de OTRAS organizaciones compartidos con esta org que estén asignadas al usuario actual. Complementa el endpoint normal `/my-work` (que solo devuelve tareas de la propia org). member+.",
        "responses": {
          "200": {
            "description": "Tareas cross-org asignadas al usuario (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/MyWorkTask"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/automations": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listAutomationRules",
        "x-tool": {
          "name": "list_automation_rules",
          "domain": "automations",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "automations"
        ],
        "summary": "Listar reglas de automatización",
        "description": "Reglas de la organización ordenadas por nombre. member+.",
        "responses": {
          "200": {
            "description": "Lista de reglas (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/AutomationRule"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Recurso inexistente o no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createAutomationRule",
        "x-tool": {
          "name": "create_automation_rule",
          "domain": "automations",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "automations"
        ],
        "summary": "Crear regla de automatización",
        "description": "Crea una regla activa. `trigger_event` es uno de `task_created`, `task_status_changed`, `task_assigned`. `conditions` es una lista de hasta 10 filtros `{field, op, value}` sobre campos whitelisteados de la tarea: `status`, `priority`, `type`, `project_id`, `assignee_id`. `action` es uno de `set_priority`, `set_status`, `assign`, `add_comment`, `notify`. Requiere admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AutomationRuleCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Regla creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AutomationRule"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Recurso inexistente o no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/automations/{rule_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "rule_id",
          "in": "path",
          "required": true,
          "description": "UUID de la regla de automatización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getAutomationRule",
        "x-tool": {
          "name": "get_automation_rule",
          "domain": "automations",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "automations"
        ],
        "summary": "Obtener regla de automatización",
        "description": "Detalle de una regla. member+.",
        "responses": {
          "200": {
            "description": "Regla.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AutomationRule"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Recurso inexistente o no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateAutomationRule",
        "x-tool": {
          "name": "update_automation_rule",
          "domain": "automations",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "automations"
        ],
        "summary": "Actualizar regla de automatización",
        "description": "PATCH parcial de la regla. admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AutomationRuleUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Regla actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AutomationRule"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Recurso inexistente o no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteAutomationRule",
        "x-tool": {
          "name": "delete_automation_rule",
          "domain": "automations",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "automations"
        ],
        "summary": "Eliminar regla de automatización",
        "description": "Borra permanentemente la regla. admin+.",
        "responses": {
          "204": {
            "description": "Regla eliminada; sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Recurso inexistente o no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/automations/{rule_id}/test": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización que actúa.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "rule_id",
          "in": "path",
          "required": true,
          "description": "UUID de la regla de automatización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "testAutomationRule",
        "x-tool": {
          "name": "test_automation_rule",
          "domain": "automations",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "automations"
        ],
        "summary": "Probar regla en seco (dry-run)",
        "description": "Evalúa la regla contra `task_id` sin aplicar ningún cambio. Devuelve si las condiciones harían match y qué acción se aplicaría. admin+.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "task_id"
                ],
                "properties": {
                  "task_id": {
                    "type": "string",
                    "format": "uuid",
                    "description": "UUID de la tarea contra la que evaluar la regla."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Resultado del dry-run.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AutomationActionResult"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Recurso inexistente o no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/agency/overview": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "agencyOverview",
        "x-tool": {
          "name": "agency_overview",
          "domain": "crm",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "agency"
        ],
        "summary": "Resumen de agencia multi-cliente",
        "description": "Roll-up por cliente para organizaciones que gestionan múltiples clientes. Por cada cliente de la org devuelve: proyectos (contratos vinculados), tareas abiertas y resumen financiero (facturado y pendiente). También incluye totales globales de la organización. Requiere ser miembro.\n\n**Nota de esquema**: los totales financieros agrupan las facturas por `invoices.client_id` (FK estable a `clients`); las facturas sin ficha vinculada (`client_id` nulo: importadas o creadas escribiendo el nombre) se atribuyen por coincidencia exacta de `client_name`, que es su único vínculo. Agrupar por nombre a secas ponía el «facturado» de un cliente a 0 en cuanto se corregía su ficha, porque `client_name` es un snapshot de la factura y no cambia con ella. Los \"proyectos\" son contratos org-scoped con `client_id` (es el mejor proxy disponible en el esquema actual, que no tiene FK `projects`→`clients`). El campo `schema_note` en cada fila de cliente documenta esto.",
        "responses": {
          "200": {
            "description": "Resumen de agencia con métricas por cliente y totales de la org.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgencyOverview"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/holdings": {
      "get": {
        "operationId": "listMyHoldings",
        "tags": [
          "holdings"
        ],
        "summary": "Listar mis holdings",
        "description": "Holdings en los que el usuario autenticado tiene fila en `holding_members`. El rol en la organización matriz NO da acceso por sí solo.",
        "responses": {
          "200": {
            "description": "Holdings del usuario.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Holding"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createHolding",
        "tags": [
          "holdings"
        ],
        "summary": "Crear un holding",
        "description": "Crea un grupo empresarial cuya MATRIZ es una organización existente. Lo crea el `owner` de esa organización. La matriz queda unida a su propio holding (un consolidado la incluye) y el creador entra como `owner` en `holding_members`. Requiere la feature `holdings` (plan Holding) en la matriz; las filiales conservan su propio plan — el holding NO agrupa facturación.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/HoldingCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Holding creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Holding"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es `owner` de la organización matriz, o el plan no incluye la feature `holdings` (`feature_not_in_plan`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o el usuario no es miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "La organización ya es matriz de un holding (`holding_already_exists`) o ya pertenece a uno (`organization_already_in_holding`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/holdings/{holding_id}": {
      "parameters": [
        {
          "name": "holding_id",
          "in": "path",
          "required": true,
          "description": "UUID del holding.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getHolding",
        "tags": [
          "holdings"
        ],
        "summary": "Detalle de un holding",
        "description": "404 homogéneo si el holding no existe o el usuario no tiene fila en `holding_members` (nunca se revela la existencia).",
        "responses": {
          "200": {
            "description": "Holding.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Holding"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No existe o sin acceso (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "updateHolding",
        "tags": [
          "holdings"
        ],
        "summary": "Renombrar un holding",
        "description": "AUTORIZACIÓN: solo el `owner` de la organización MATRIZ.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/HoldingUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Holding actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Holding"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es `owner` de la organización matriz.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No existe o sin acceso (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "dissolveHolding",
        "tags": [
          "holdings"
        ],
        "summary": "Disolver un holding",
        "description": "AUTORIZACIÓN: solo el `owner` de la organización MATRIZ.\n\nSaca del grupo a TODAS las organizaciones —incluida la matriz y las filiales que estén en la papelera— y borra el holding. Las organizaciones no se tocan: siguen existiendo, sueltas.\n\nEs la ÚNICA forma de deshacer un grupo. La matriz no puede soltarse de sí misma (409 `cannot_detach_parent_org` en `detachHoldingSubsidiary`) y borrar su organización es un soft-delete, así que el holding sobreviviría con sus filiales dentro.",
        "responses": {
          "204": {
            "description": "Holding disuelto."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es `owner` de la organización matriz.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No existe o sin acceso (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/holdings/{holding_id}/subsidiaries": {
      "parameters": [
        {
          "name": "holding_id",
          "in": "path",
          "required": true,
          "description": "UUID del holding.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "attachHoldingSubsidiary",
        "tags": [
          "holdings"
        ],
        "summary": "Adjuntar una filial al holding",
        "description": "Adjunta una organización al holding (escribe `organizations.holding_id`).\n\n**DOBLE LLAVE de autorización**: hay que ser `owner` de la organización MATRIZ **y** `owner`/`admin` de la organización que se adjunta. Lo segundo impide absorber una organización ajena conociendo su UUID: si el caller no es miembro de ella recibe 404 (no se revela si existe) y si lo es pero su rol no llega a `admin`, 403.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/HoldingSubsidiaryAttach"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Filial adjuntada; devuelve el holding actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Holding"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es `owner` de la matriz, no administra la organización que adjunta, o el plan no incluye `holdings` (`feature_not_in_plan`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Holding sin acceso, u organización inexistente / no miembro.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ya pertenece a un holding (`organization_already_in_holding`) o es matriz de otro (`organization_is_holding_parent`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/holdings/{holding_id}/subsidiaries/{organization_id}": {
      "parameters": [
        {
          "name": "holding_id",
          "in": "path",
          "required": true,
          "description": "UUID del holding.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "organization_id",
          "in": "path",
          "required": true,
          "description": "UUID de la filial que se suelta.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "detachHoldingSubsidiary",
        "tags": [
          "holdings"
        ],
        "summary": "Soltar una filial del holding",
        "description": "Pone `organizations.holding_id = NULL`. **Revocación inmediata**: el conjunto consolidado se recalcula en cada petición, así que la filial desaparece del panel en la siguiente lectura.\n\nAUTORIZACIÓN: solo el `owner` de la organización MATRIZ.",
        "responses": {
          "204": {
            "description": "Filial soltada."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es `owner` de la organización matriz.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Holding sin acceso o la organización no pertenece a este holding.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Es la organización MATRIZ (`cannot_detach_parent_org`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/holdings/{holding_id}/members": {
      "parameters": [
        {
          "name": "holding_id",
          "in": "path",
          "required": true,
          "description": "UUID del holding.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listHoldingMembers",
        "tags": [
          "holdings"
        ],
        "summary": "Listar miembros del holding",
        "description": "Quién tiene acceso al consolidado. Sin PII: solo `user_id` y rol. Visible para cualquier miembro del holding.",
        "responses": {
          "200": {
            "description": "Miembros del holding.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/HoldingMember"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No existe o sin acceso (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "addHoldingMember",
        "tags": [
          "holdings"
        ],
        "summary": "Dar acceso al holding a un usuario",
        "description": "AUTORIZACIÓN: solo el `owner` de la organización MATRIZ. El destinatario debe pertenecer a alguna organización del grupo (404 en caso contrario): el acceso consolidado no se concede a un usuario ajeno por conocer su UUID.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/HoldingMemberCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Miembro añadido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HoldingMember"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es `owner` de la organización matriz.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Holding sin acceso o usuario ajeno al grupo (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ya es miembro (`member_exists`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/holdings/{holding_id}/members/{member_id}": {
      "parameters": [
        {
          "name": "holding_id",
          "in": "path",
          "required": true,
          "description": "UUID del holding.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "member_id",
          "in": "path",
          "required": true,
          "description": "UUID de la fila de `holding_members`.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "updateHoldingMember",
        "tags": [
          "holdings"
        ],
        "summary": "Cambiar el rol de un miembro del holding",
        "description": "AUTORIZACIÓN: solo el `owner` de la organización MATRIZ. 409 si el cambio dejaría al holding sin ningún `owner`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/HoldingMemberUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Miembro actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HoldingMember"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es `owner` de la organización matriz.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Holding sin acceso o miembro inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Dejaría al holding sin propietario (`last_holding_owner`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "removeHoldingMember",
        "tags": [
          "holdings"
        ],
        "summary": "Quitar acceso al holding",
        "description": "AUTORIZACIÓN: solo el `owner` de la organización MATRIZ. 409 si dejaría al holding sin ningún `owner`.",
        "responses": {
          "204": {
            "description": "Acceso retirado."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es `owner` de la organización matriz.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Holding sin acceso o miembro inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Dejaría al holding sin propietario (`last_holding_owner`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/holdings/{holding_id}/visibility": {
      "parameters": [
        {
          "name": "holding_id",
          "in": "path",
          "required": true,
          "description": "UUID del holding.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listHoldingVisibility",
        "tags": [
          "holdings"
        ],
        "summary": "Configuración de visibilidad por filial",
        "description": "Qué bloques de datos (`finance`, `projects`, `people`) ve el holding de cada FILIAL. Por defecto TODO visible: una filial sin configuración guardada devuelve los tres bloques a `true`. La organización MATRIZ no aparece: es la empresa del propio holding y no se oculta datos a sí misma.\n\nLo lee cualquier miembro del holding —el panel necesita saber qué bloque falta para no pintar un 0 engañoso, y saberlo no revela ningún dato de la filial—, pero cambiarlo exige ser `owner` de la organización MATRIZ.",
        "responses": {
          "200": {
            "description": "Configuración efectiva de cada filial.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/HoldingVisibility"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No existe o sin acceso (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/holdings/{holding_id}/visibility/{organization_id}": {
      "parameters": [
        {
          "name": "holding_id",
          "in": "path",
          "required": true,
          "description": "UUID del holding.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "organization_id",
          "in": "path",
          "required": true,
          "description": "UUID de la FILIAL cuya visibilidad se configura.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "updateHoldingVisibility",
        "tags": [
          "holdings"
        ],
        "summary": "Cambiar qué datos ve el holding de una filial",
        "description": "AUTORIZACIÓN: solo el `owner` de la organización MATRIZ (ser `owner` en `holding_members` no basta).\n\nPATCH parcial: los bloques que no se envían no se tocan. Ocultar un bloque lo retira de `/summary` y `/finance` en la petición siguiente y ADEMÁS deja de sumar en los totales consolidados — si siguiera sumando, el dato oculto se reconstruiría restando el total menos las filas visibles.\n\nLa organización se valida contra el conjunto resuelto en servidor: un id ajeno al holding devuelve 404, así que este endpoint tampoco sirve para descubrir organizaciones.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/HoldingVisibilityUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Configuración efectiva tras el cambio.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HoldingVisibility"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es `owner` de la organización matriz.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Holding sin acceso, o la organización no es filial de este holding (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Es la organización MATRIZ, que no puede ocultarse datos a sí misma (`cannot_configure_parent_org`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/holdings/{holding_id}/summary": {
      "parameters": [
        {
          "name": "holding_id",
          "in": "path",
          "required": true,
          "description": "UUID del holding.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getHoldingSummary",
        "tags": [
          "holdings"
        ],
        "summary": "Resumen consolidado del holding",
        "description": "Consolidado de actividad del grupo (matriz + filiales), SOLO LECTURA: nº de filiales, proyectos totales/activos, tareas por estado y personas, más el desglose por organización que pinta la tabla del panel.\n\n`people_total` cuenta usuarios DISTINTOS: quien tenga membresía en dos filiales cuenta UNA vez (por eso NO coincide con la suma de `people` de cada fila). Sin PII de empleados: solo agregados.\n\nLas organizaciones consolidadas se resuelven EN SERVIDOR desde `organizations.holding_id`; una filial soltada desaparece de inmediato.",
        "responses": {
          "200": {
            "description": "Resumen consolidado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HoldingSummary"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No existe o sin acceso (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/holdings/{holding_id}/finance": {
      "parameters": [
        {
          "name": "holding_id",
          "in": "path",
          "required": true,
          "description": "UUID del holding.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "from",
          "in": "query",
          "required": false,
          "description": "Fecha inicial (facturas por `issue_date`, gastos por `date`).",
          "schema": {
            "type": "string",
            "format": "date"
          }
        },
        {
          "name": "to",
          "in": "query",
          "required": false,
          "description": "Fecha final (facturas por `issue_date`, gastos por `date`).",
          "schema": {
            "type": "string",
            "format": "date"
          }
        }
      ],
      "get": {
        "operationId": "getHoldingFinance",
        "tags": [
          "holdings"
        ],
        "summary": "Finanzas consolidadas del holding",
        "description": "Ingresos, gastos y neto consolidados del grupo, SOLO LECTURA, con desglose por filial.\n\n**Neteo intercompañía**: las facturas emitidas entre empresas del mismo holding se excluyen por los DOS lados — el gasto espejo en la sociedad receptora y el ingreso equivalente en la emisora. Sin ese neteo el P&L del grupo saldría inflado (el mismo euro contaría como venta y como coste). `gross_*` son los importes antes de netear e `intercompany_*_excluded` la diferencia.\n\n**Divisa**: se consolida POR DIVISA (una fila por `currency`); no se aplica ninguna conversión ni se suman divisas distintas.",
        "responses": {
          "200": {
            "description": "Finanzas consolidadas.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HoldingFinance"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No existe o sin acceso (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/attachments": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "uploadAttachment",
        "tags": [
          "attachments"
        ],
        "summary": "Subir adjunto",
        "description": "Sube un fichero adjunto (multipart/form-data, campo `file`). Extensiones permitidas: png, jpg, jpeg, webp, gif, pdf. Las imágenes se validan con Pillow (protección anti-polyglot). Máximo 10 MB. El nombre almacenado es aleatorio (nunca el del cliente). Si se pasan `entity_type`+`entity_id`, valida que la entidad pertenece a la organización (IDOR guard).",
        "parameters": [
          {
            "name": "entity_type",
            "in": "query",
            "required": false,
            "description": "Tipo de entidad propietaria. Además de las entidades de negocio (invoice, expense, supplier_invoice, task, payslip, client), admite los contenedores de imágenes de marca (organization, project, user).",
            "schema": {
              "type": "string",
              "enum": [
                "invoice",
                "expense",
                "supplier_invoice",
                "task",
                "payslip",
                "client",
                "organization",
                "project",
                "user"
              ]
            }
          },
          {
            "name": "entity_id",
            "in": "query",
            "required": false,
            "description": "UUID de la entidad propietaria.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "Fichero a subir (máx. 10 MB)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Adjunto subido correctamente.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Attachment"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Entidad no encontrada (IDOR guard).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "Fichero demasiado grande.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Extensión o tipo de fichero no permitido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "get": {
        "operationId": "listAttachments",
        "x-tool": {
          "name": "list_attachments",
          "domain": "attachments",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "attachments"
        ],
        "summary": "Listar adjuntos de una entidad",
        "description": "Devuelve los adjuntos asociados a una entidad concreta. Requiere ser miembro de la organización; valida pertenencia a la org (IDOR).",
        "parameters": [
          {
            "name": "entity_type",
            "in": "query",
            "required": true,
            "description": "Tipo de entidad propietaria. Además de las entidades de negocio (invoice, expense, supplier_invoice, task, payslip, client), admite los contenedores de imágenes de marca (organization, project, user).",
            "schema": {
              "type": "string",
              "enum": [
                "invoice",
                "expense",
                "supplier_invoice",
                "task",
                "payslip",
                "client",
                "organization",
                "project",
                "user"
              ]
            }
          },
          {
            "name": "entity_id",
            "in": "query",
            "required": true,
            "description": "UUID de la entidad.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de adjuntos (puede estar vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Attachment"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Entidad no encontrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/attachments/base64": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "uploadAttachmentBase64",
        "x-tool": {
          "name": "upload_attachment",
          "domain": "attachments",
          "profile": "core",
          "sensitive": false
        },
        "tags": [
          "attachments"
        ],
        "summary": "Adjuntar fichero (base64) a una entidad org-scoped",
        "description": "Adjunta un fichero codificado en base64 (JSON) a CUALQUIER entidad org-scoped con adjuntos: `invoice`, `expense`, `task`, `supplier_invoice`, `payslip` o `client`. A diferencia de la variante project-scoped, no exige `project_id`, así que permite adjuntar por MCP a ingresos y gastos (que no cuelgan de un proyecto). Valida que la entidad pertenece a la organización (IDOR guard) y, para documentos financieros o de nómina, exige rol admin+. Máx 10 MB el fichero (no el base64); extensiones png/jpg/jpeg/webp/gif/pdf.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AttachmentUploadBase64"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Adjunto creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Attachment"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Documento sensible y rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Entidad no encontrada o de otra organización (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "Fichero mayor de 10 MB.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "base64 inválido, extensión no permitida o entity_type desconocido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/attachments": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto (la ruta lo fija; permite un PAT scoped a él).",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "uploadProjectAttachment",
        "tags": [
          "attachments"
        ],
        "summary": "Adjuntar fichero (base64) a una entidad del proyecto",
        "description": "Adjunta un fichero codificado en base64 (JSON) a una entidad de ESTE proyecto: `task` o `supplier_invoice`. Al llevar `{project_id}` en la ruta funciona con un PAT scoped a ese proyecto (el endpoint org-level multipart lo rechaza por scope). Máx 10 MB el fichero (no el base64); extensiones png/jpg/jpeg/webp/gif/pdf.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "entity_type",
                  "entity_id",
                  "filename",
                  "content_base64"
                ],
                "properties": {
                  "entity_type": {
                    "type": "string",
                    "enum": [
                      "task",
                      "supplier_invoice"
                    ],
                    "description": "Tipo de entidad (debe pertenecer a este proyecto)."
                  },
                  "entity_id": {
                    "type": "string",
                    "format": "uuid",
                    "description": "UUID de la entidad del proyecto."
                  },
                  "filename": {
                    "type": "string",
                    "description": "Nombre original; su extensión decide el tipo permitido."
                  },
                  "content_base64": {
                    "type": "string",
                    "description": "Contenido del fichero en base64."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Adjunto creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Attachment"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "PAT scoped a otro proyecto (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Entidad no encontrada en el proyecto (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "Fichero mayor de 10 MB.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "base64 inválido, extensión no permitida o entity_type no project-scoped.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/attachments/{stored_name}/download": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "stored_name",
          "in": "path",
          "required": true,
          "description": "Nombre almacenado del fichero (aleatorio, con extensión).",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "downloadAttachment",
        "tags": [
          "attachments"
        ],
        "summary": "Descargar un adjunto",
        "description": "Descarga autenticada del fichero (PJKT-2016). Sustituye a `/uploads/attachments/<stored_name>`, que servía nginx gateado con `auth_request`; ese camino sigue vivo para las filas que aún no se han movido a StorageService.\nRepite la misma puerta que el gateado de nginx: pertenencia a la organización dueña y, para documentos financieros o de nómina (`invoice`, `expense`, `supplier_invoice`, `payslip`), rol admin o superior. Un adjunto de otra organización devuelve **404 y no 403**: distinguirlos confirmaría que ese fichero existe en alguna parte.\nSe identifica por `stored_name` —el nombre aleatorio del fichero, que es lo que aparece en `file_url`— y no por el id de la fila, para que el frontend pueda seguir abriendo lo que diga `file_url` sin cambiar nada.",
        "responses": {
          "200": {
            "description": "El fichero.",
            "content": {
              "application/octet-stream": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "description": "Sin sesión (`unauthorized`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Documento sensible y rol insuficiente (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No existe, o no es de tu organización (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/attachments/{attachment_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "attachment_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "deleteAttachment",
        "x-tool": {
          "name": "delete_attachment",
          "domain": "attachments",
          "profile": "all",
          "sensitive": false
        },
        "tags": [
          "attachments"
        ],
        "summary": "Borrar adjunto",
        "description": "Borra el adjunto y su fichero del disco. Solo puede borrar el uploader o un admin/owner de la organización.",
        "responses": {
          "204": {
            "description": "Adjunto borrado."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permiso para borrar (no es uploader ni admin+).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Adjunto no encontrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/invoices/{invoice_id}/comments": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "invoice_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listInvoiceComments",
        "x-tool": {
          "name": "list_invoice_comments",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Listar comentarios de factura",
        "description": "Devuelve el hilo de comentarios de la factura (lista plana, recientes primero). El cliente reconstruye el árbol por `parent_id`.\n\nPaginable con `limit`/`offset` (retrocompatible: sin parámetros devuelve como mucho 200 comentarios). La cabecera `X-Total-Count` trae el total de comentarios de la factura, sin paginar. Ojo: al paginar, un hilo puede quedar partido entre páginas.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de comentarios (puede estar vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de comentarios de la factura (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/FinanceComment"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Factura no encontrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createInvoiceComment",
        "x-tool": {
          "name": "create_invoice_comment",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Crear comentario de factura",
        "description": "Crea un comentario (o respuesta) en el hilo de la factura. Requiere ser miembro de la organización.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "body"
                ],
                "properties": {
                  "body": {
                    "type": "string",
                    "description": "Texto del comentario (máx. 5000 caracteres).",
                    "examples": [
                      "Factura revisada; pendiente de aprobación."
                    ]
                  },
                  "parent_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "UUID del comentario padre (respuesta a un hilo)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Comentario creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FinanceComment"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Factura no encontrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo vacío o demasiado largo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/invoices/{invoice_id}/comments/{comment_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "invoice_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "comment_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "updateInvoiceComment",
        "x-tool": {
          "name": "update_invoice_comment",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Editar comentario de factura",
        "description": "Edita el cuerpo de un comentario. Solo el autor o admin+ puede editar.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "body"
                ],
                "properties": {
                  "body": {
                    "type": "string",
                    "description": "Nuevo cuerpo del comentario."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Comentario editado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FinanceComment"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permiso para editar.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Comentario no encontrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo vacío o demasiado largo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteInvoiceComment",
        "x-tool": {
          "name": "delete_invoice_comment",
          "domain": "finance",
          "profile": "all",
          "sensitive": true
        },
        "tags": [
          "finance"
        ],
        "summary": "Borrar comentario de factura",
        "description": "Borra un comentario. Solo el autor o admin+ puede borrar.",
        "responses": {
          "204": {
            "description": "Comentario borrado."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permiso para borrar.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Comentario no encontrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/expenses/{expense_id}/comments": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "expense_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listExpenseComments",
        "tags": [
          "finance"
        ],
        "summary": "Listar comentarios de gasto",
        "description": "Devuelve el hilo de comentarios del gasto (lista plana, recientes primero). El cliente reconstruye el árbol por `parent_id`.\n\nPaginable con `limit`/`offset` (retrocompatible: sin parámetros devuelve como mucho 200 comentarios). La cabecera `X-Total-Count` trae el total de comentarios del gasto, sin paginar. Ojo: al paginar, un hilo puede quedar partido entre páginas.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Número máximo de resultados por página (1–200, defecto 200).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 200
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Número de filas a saltar (paginación). 0 = primera página.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de comentarios (puede estar vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de comentarios del gasto (sin paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/FinanceComment"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Gasto no encontrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createExpenseComment",
        "tags": [
          "finance"
        ],
        "summary": "Crear comentario de gasto",
        "description": "Crea un comentario (o respuesta) en el hilo del gasto. Requiere ser miembro de la organización.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "body"
                ],
                "properties": {
                  "body": {
                    "type": "string",
                    "description": "Texto del comentario (máx. 5000 caracteres).",
                    "examples": [
                      "Gasto revisado; falta adjuntar el ticket."
                    ]
                  },
                  "parent_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uuid",
                    "description": "UUID del comentario padre (respuesta a un hilo)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Comentario creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FinanceComment"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Gasto no encontrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo vacío o demasiado largo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/expenses/{expense_id}/comments/{comment_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "expense_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "comment_id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "updateExpenseComment",
        "tags": [
          "finance"
        ],
        "summary": "Editar comentario de gasto",
        "description": "Edita el cuerpo de un comentario. Solo el autor o admin+ puede editar.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "body"
                ],
                "properties": {
                  "body": {
                    "type": "string",
                    "description": "Nuevo cuerpo del comentario."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Comentario editado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FinanceComment"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permiso para editar.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Comentario no encontrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo vacío o demasiado largo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deleteExpenseComment",
        "tags": [
          "finance"
        ],
        "summary": "Borrar comentario de gasto",
        "description": "Borra un comentario. Solo el autor o admin+ puede borrar.",
        "responses": {
          "204": {
            "description": "Comentario borrado."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Sin permiso para borrar.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Comentario no encontrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/integrations/github/oauth/start": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "githubOauthStart",
        "tags": [
          "github"
        ],
        "summary": "Iniciar flujo OAuth GitHub para conectar la integración",
        "description": "Genera un state JWT firmado (HS256, APP_SECRET, TTL 10 min) con claims {org_id, user_id, purpose:\"github_repo_oauth\", exp} y devuelve la URL de autorización de GitHub con scope=\"repo\".\n**Nota sobre el scope**: GitHub OAuth clásico no ofrece un scope de solo lectura para repositorios privados. El scope mínimo que permite listar y acceder a repos privados es \"repo\" (lectura + escritura). La interfaz debe informar al usuario del alcance concedido antes de iniciar el flujo.\nRequiere manager+.\n",
        "responses": {
          "200": {
            "description": "URL de autorización de GitHub.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "url"
                  ],
                  "properties": {
                    "url": {
                      "type": "string",
                      "description": "URL de GitHub para redirigir al usuario."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Requiere manager o superior.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada (o no pertenece al usuario).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/oauth/github/callback/repo-integration": {
      "get": {
        "operationId": "githubOauthCallback",
        "tags": [
          "github"
        ],
        "summary": "Callback OAuth GitHub (redirección de navegador)",
        "description": "Recibe `code` y `state` de GitHub tras la autorización del usuario. Este endpoint es **navegación directa del browser**, no se debe llamar con `fetch` ni `XMLHttpRequest`.\nValida el state JWT (firma, expiración, purpose=\"github_repo_oauth\" y state.user_id == usuario de la sesión activa). Si el user_id no coincide devuelve 403 (no una redirección a error, para evitar suplantar sesiones).\nIntercambia el code por un access_token (POST access_token GitHub, timeout 8s). Llama GET /user para obtener el login. Hace UPSERT en org_github_integrations con el token cifrado (Fernet).\nRedirige 302 al frontend: - Éxito → `{web_url}/projects?github=connected` - Error → `{web_url}/projects?github=error&reason=<code>`\n  - `state_invalid` — JWT inválido, caducado, purpose incorrecto o code/state ausentes.\n  - `exchange_failed` — GitHub rechazó el code (4xx en access_token).\n  - `github_error` — GET /user de GitHub falló.\n\nNUNCA se filtra el detalle del error de GitHub en la URL de redirección. Requiere sesión de usuario activa (cookie).\n",
        "parameters": [
          {
            "name": "code",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Código de autorización de GitHub."
          },
          {
            "name": "state",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "JWT de state generado por /oauth/start."
          }
        ],
        "responses": {
          "302": {
            "description": "Redirección al frontend. Éxito → ?github=connected. Error → ?github=error&reason=<code>.\n",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                },
                "description": "URL de destino tras completar el flujo OAuth."
              }
            }
          },
          "401": {
            "description": "No autenticado (sin sesión de usuario activa).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "user_id del state no coincide con el usuario de la sesión.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/integrations/github": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "put": {
        "operationId": "connectGithubIntegration",
        "tags": [
          "github"
        ],
        "summary": "Conectar/actualizar integración GitHub de la org",
        "description": "Valida el token contra GitHub (GET /user), lo cifra con Fernet y lo persiste. Solo se guarda el token cifrado; `token_last4` permite identificarlo visualmente. Requiere admin+.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "token"
                ],
                "properties": {
                  "token": {
                    "type": "string",
                    "minLength": 10,
                    "description": "Personal Access Token de GitHub (ghp_ o github_pat_)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Integración conectada (o actualizada).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GithubIntegration"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Requiere admin o superior.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada (o no pertenece al usuario).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Token de GitHub inválido (code github_token_invalid).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "get": {
        "operationId": "getGithubIntegrationStatus",
        "tags": [
          "github"
        ],
        "summary": "Estado de la integración GitHub",
        "description": "Devuelve `{connected, login?, token_last4?}`. No expone el token cifrado. El login se cachea en la tabla al conectar para no llamar a GitHub en cada GET.\n",
        "responses": {
          "200": {
            "description": "Estado de la integración.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GithubIntegration"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Requiere member o superior.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "disconnectGithubIntegration",
        "tags": [
          "github"
        ],
        "summary": "Desconectar integración GitHub",
        "description": "Elimina el token cifrado de la org. Requiere admin+.",
        "responses": {
          "204": {
            "description": "Integración eliminada."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Requiere admin o superior.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada o sin integración conectada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/integrations/github/repos": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listGithubRepos",
        "tags": [
          "github"
        ],
        "summary": "Listar repositorios del token GitHub",
        "description": "Lista los repos de la cuenta GitHub (hasta 50, ordenados por último push). Filtro substring por `query`. 409 `github_not_connected` si no hay integración. Requiere member+.\n",
        "parameters": [
          {
            "name": "query",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "default": ""
            },
            "description": "Filtro substring por nombre completo del repo."
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de repos (puede estar vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "full_name": {
                        "type": "string"
                      },
                      "private": {
                        "type": "boolean"
                      },
                      "default_branch": {
                        "type": [
                          "string",
                          "null"
                        ]
                      },
                      "pushed_at": {
                        "type": [
                          "string",
                          "null"
                        ]
                      },
                      "description": {
                        "type": [
                          "string",
                          "null"
                        ]
                      }
                    },
                    "required": [
                      "full_name",
                      "private"
                    ]
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Requiere member o superior.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "No hay integración GitHub conectada (code github_not_connected).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/repository": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "linkRepository",
        "tags": [
          "github"
        ],
        "summary": "Vincular repositorio GitHub a un proyecto",
        "description": "Verifica que el repo existe en la cuenta de GitHub, obtiene la rama predeterminada y persiste el vínculo. Un proyecto puede tener varios repos vinculados. Requiere admin+.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "repo_full_name"
                ],
                "properties": {
                  "repo_full_name": {
                    "type": "string",
                    "minLength": 3,
                    "description": "Nombre completo del repo, p. ej. 'acme/frontend'.",
                    "example": "acme/frontend"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Vínculo creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProjectRepository"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Requiere admin o superior.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto o repositorio no encontrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "El repositorio ya está vinculado o no hay integración GitHub.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "get": {
        "operationId": "listProjectRepositories",
        "tags": [
          "github"
        ],
        "summary": "Listar repositorios vinculados a un proyecto",
        "responses": {
          "200": {
            "description": "Lista de vínculos (puede estar vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ProjectRepository"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Requiere member o superior.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto no encontrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/repository/{link_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "link_id",
          "in": "path",
          "required": true,
          "description": "UUID del vínculo (fila project_repositories).",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "unlinkRepository",
        "tags": [
          "github"
        ],
        "summary": "Desvincular repositorio de un proyecto",
        "description": "Requiere admin+.",
        "responses": {
          "204": {
            "description": "Vínculo eliminado."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Requiere admin o superior.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Vínculo o proyecto no encontrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/github/activity": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getProjectGithubActivity",
        "tags": [
          "github"
        ],
        "summary": "Actividad GitHub del proyecto",
        "description": "Agrega commits (últimos 15), PRs open (máx. 10) y PRs mergeados (máx. 10) de todos los repos vinculados al proyecto. Respuestas cacheadas en memoria 60 s para no quemar el rate limit de GitHub. 409 si no hay integración. Requiere member+.\n",
        "responses": {
          "200": {
            "description": "Actividad agregada por repositorio.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GithubActivity"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Requiere member o superior.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto no encontrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "No hay integración GitHub conectada (code github_not_connected).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/github/delivery-metrics": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "githubDeliveryMetrics",
        "tags": [
          "github"
        ],
        "summary": "Métricas de entrega (DORA-lite) del proyecto",
        "description": "Calcula métricas de entrega DORA-lite agregando los PRs mergeados de todos los repos vinculados al proyecto: tiempo medio de entrega (lead time, de creación a merge) y volumen de merges en la ventana observada. Requiere member+.\n",
        "responses": {
          "200": {
            "description": "Métricas de entrega agregadas del proyecto.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeliveryMetrics"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Requiere member o superior.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto no encontrado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/tasks/{task_id}/github-links": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "description": "UUID de la tarea.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getTaskGithubLinks",
        "tags": [
          "github"
        ],
        "summary": "Commits y PRs de GitHub vinculados a una tarea",
        "description": "Busca en la GitHub Search API los commits y PRs que mencionan la referencia de la tarea ({project_key}-{number}) en cada repo vinculado al proyecto.\nCache in-memory TTL 120 s por (repo, referencia).\nSi GitHub devuelve 403 rate-limit: `rate_limited=true`, listas vacías (NO 429 al cliente — la tarjeta muestra un aviso suave).\nSin integración GitHub o sin repos vinculados: `repos=[]`.\n404 si la tarea no pertenece al proyecto/org. Requiere member+.\n",
        "responses": {
          "200": {
            "description": "Commits y PRs encontrados, agrupados por repo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GithubTaskLinks"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Requiere member o superior.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto o tarea no encontrada (o no pertenece a la org).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/projects/{project_id}/github/pulls/{number}/review": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "number",
          "in": "path",
          "required": true,
          "description": "Número del pull request en GitHub.",
          "schema": {
            "type": "integer",
            "minimum": 1
          }
        }
      ],
      "get": {
        "operationId": "getGithubPullReview",
        "tags": [
          "github"
        ],
        "summary": "Diff y comentarios de revisión de un pull request (solo lectura)",
        "description": "Trae los ficheros cambiados de un PR (`GET /pulls/{n}/files`) y sus comentarios de revisión (`GET /pulls/{n}/comments`) para enseñarlos junto a la tarea enlazada.\n\n**Solo lectura, por diseño.** No existe el POST hermano de este GET: un comentario escrito desde aquí lo firmaría el token de la integración de la ORGANIZACIÓN, no la persona que revisa, y en una herramienta de revisión «quién dijo esto» es parte del contenido. Hacerlo bien pide OAuth por usuario contra GitHub (almacenamiento de tokens, renovación, revocación) y esa decisión de producto no está tomada.\n\nEl repo va en `repo` (query) y NO se confía en él: tiene que estar vinculado a ESE proyecto de ESA organización (`project_repositories`), o la respuesta es 404. Un miembro de la org A no puede leer así el diff de un repo de la org B.\n\nTopes, y se dicen en la respuesta — nunca se recorta en silencio: 30 ficheros (`files_truncated`), 12 KB de parche por fichero y 200 KB de parche en toda la respuesta (`patch_truncated` en el fichero afectado), 99 comentarios (`comments_truncated`). El tope de comentarios es 99 y no 100 a propósito: a GitHub se le pide su página máxima (100) y se guarda una menos, porque el comentario sobrante es la prueba de que hay más. Comparar 100 contra 100 daría un flag que nunca puede dispararse — es decir, un recorte silencioso.\n\nCache in-memory de 120 s por (token, repo, número) — el mismo patrón que el resto del módulo, para no gastar una llamada externa por render. Si GitHub agota la cuota al pedir ficheros o comentarios, esas listas vienen vacías con `rate_limited: true`; si la agota ya al leer el PR, 429. Si esas peticiones fallan por otro motivo (502, 500, timeout), vienen vacías con `fetch_failed: true` — «vacío» y «no se pudo leer» no se confunden. Ninguna respuesta degradada (por cuota o por fallo) se cachea. Requiere member+.\n",
        "parameters": [
          {
            "name": "repo",
            "in": "query",
            "required": true,
            "description": "Nombre completo del repo (owner/name). Debe estar vinculado al proyecto; si no lo está, 404.\n",
            "schema": {
              "type": "string",
              "minLength": 3
            },
            "example": "acme/frontend"
          }
        ],
        "responses": {
          "200": {
            "description": "Ficheros cambiados y comentarios de revisión del PR.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GithubPullReview"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Requiere member o superior.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proyecto no encontrado en la org, repo no vinculado a ese proyecto, o el PR no existe en GitHub.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "No hay integración GitHub conectada (code github_not_connected).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Parámetros inválidos (p. ej. `repo` ausente o `number` no numérico).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "GitHub agotó la cuota antes de poder leer el PR (code too_many_requests).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/integrations/github/webhook": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "provisionGithubWebhook",
        "tags": [
          "github"
        ],
        "summary": "Generar o rotar el secreto del webhook de GitHub de la org",
        "description": "Genera (o rota) el secreto del webhook de la integración y devuelve la URL pública y el secreto. El secreto se muestra **UNA sola vez**; el servidor solo guarda su forma cifrada (Fernet). Configura ambos valores en GitHub (Settings → Webhooks). Requiere manager+ y una integración GitHub conectada.",
        "responses": {
          "200": {
            "description": "Configuración del webhook (secreto mostrado una única vez).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GithubWebhookConfig"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Requiere manager o superior.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización no encontrada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "No hay integración GitHub conectada (code github_not_connected).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/integrations/github/webhook/{webhook_id}": {
      "parameters": [
        {
          "name": "webhook_id",
          "in": "path",
          "required": true,
          "description": "Identificador opaco de la integración GitHub (resuelve la org server-side).",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "githubWebhook",
        "tags": [
          "github"
        ],
        "summary": "Receptor de webhooks de GitHub (verificación de firma HMAC)",
        "description": "Endpoint **PÚBLICO** (sin cookie ni PAT — GitHub no puede autenticarse).\nVerifica la cabecera `X-Hub-Signature-256` (HMAC-SHA256 del cuerpo crudo con el secreto por-integración) mediante comparación en tiempo constante; RECHAZA con **401 `invalid_signature`** si falta o no valida, ANTES de procesar nada.\nLa organización se resuelve por `webhook_id` (opaco, emitido por el servidor): NUNCA se confía en un id de org que venga en el payload. **404** si el `webhook_id` es desconocido.\nIdempotente por `X-GitHub-Delivery`: una reentrega con el mismo delivery id se ignora (`duplicate: true`, `processed: false`). Procesa `push`/`pull_request`/ `issues` para refrescar el estado de actividad; otros eventos (p. ej. `ping`) se aceptan sin efecto (`processed: false`).",
        "security": [],
        "parameters": [
          {
            "name": "X-Hub-Signature-256",
            "in": "header",
            "required": true,
            "description": "Firma HMAC-SHA256 del cuerpo crudo, con formato `sha256=<hexdigest>`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "X-GitHub-Event",
            "in": "header",
            "required": true,
            "description": "Tipo de evento (push, pull_request, issues, ping, …).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "X-GitHub-Delivery",
            "in": "header",
            "required": true,
            "description": "UUID de la entrega — clave de idempotencia.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "description": "Payload del evento (cuerpo crudo; la firma se verifica sobre estos bytes).",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Evento aceptado (firma válida). Ver `processed`/`duplicate`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GithubWebhookAck"
                }
              }
            }
          },
          "401": {
            "description": "Firma ausente o inválida (`invalid_signature`). El evento NO se procesa.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "webhook_id desconocido (sin integración asociada).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/places/autocomplete": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "placesAutocomplete",
        "tags": [
          "places"
        ],
        "summary": "Autocompletar direcciones",
        "description": "Proxy de `POST https://places.googleapis.com/v1/places:autocomplete`. La API key de Google se guarda en el servidor y NUNCA se expone al navegador. Filtra por regiones es/ad/fr/pt con languageCode=es. Si `input` tiene menos de 3 caracteres devuelve lista vacía sin consultar Google. Usa `session_token` para agrupar las llamadas de autocompletar + /details en una única sesión de Google — esto reduce el coste por el modelo de facturación por sesión frente al modelo por petición.",
        "parameters": [
          {
            "name": "input",
            "in": "query",
            "required": true,
            "description": "Texto parcial a autocompletar (mínimo recomendado 3 caracteres).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "session_token",
            "in": "query",
            "required": false,
            "description": "Token de sesión de Google Places (UUID generado por el cliente). Agrupa autocompletar + details para billing por sesión de Google.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista de sugerencias (puede ser vacía si input < 3 chars o sin resultados).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlaceSuggestion"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Cupo de peticiones a Places superado (`too_many_requests`). Se aplican DOS cupos independientes por ventana fija de 60 s —por usuario y por organización— y basta cruzar uno. Fail-open si Redis no responde.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Error del proveedor Google Places (`places_upstream_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "GOOGLE_MAPS_API_KEY no configurada en el servidor (`places_not_configured`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/places/details": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "placesDetails",
        "tags": [
          "places"
        ],
        "summary": "Detalles de dirección",
        "description": "Proxy de `GET https://places.googleapis.com/v1/places/{place_id}` con FieldMask `formattedAddress,addressComponents,location`. La API key de Google se guarda en el servidor y NUNCA se expone al navegador. Parsea los `addressComponents` para devolver campos de dirección estructurados: calle, número, ciudad, código postal, provincia y país. Enviar el mismo `session_token` usado en /autocomplete cierra la sesión de Google y aplica el modelo de facturación por sesión.",
        "parameters": [
          {
            "name": "place_id",
            "in": "query",
            "required": true,
            "description": "ID del lugar devuelto por el endpoint /autocomplete.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "session_token",
            "in": "query",
            "required": false,
            "description": "Token de sesión de Google Places. Debe coincidir con el enviado en /autocomplete para que Google aplique el precio por sesión.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Campos de dirección parseados. Todos son opcionales; Google no garantiza la presencia de cada componente en todas las regiones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlaceDetails"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Cupo de peticiones a Places superado (`too_many_requests`). Se aplican DOS cupos independientes por ventana fija de 60 s —por usuario y por organización— y basta cruzar uno. Fail-open si Redis no responde.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Error del proveedor Google Places (`places_upstream_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "GOOGLE_MAPS_API_KEY no configurada en el servidor (`places_not_configured`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/places/staticmap": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "placesStaticMap",
        "tags": [
          "places"
        ],
        "summary": "Mapa estático de una dirección",
        "description": "Proxy de `GET https://maps.googleapis.com/maps/api/staticmap` que devuelve la imagen PNG del mapa renderizada por Google. La petición a Google se hace server-side: la API key NUNCA llega al navegador y, si el servidor tiene configurado GOOGLE_MAPS_SIGNING_SECRET (el \"URL signing secret\" de la consola de Google), la URL se firma con HMAC-SHA1 añadiendo `&signature=` — la firma tampoco se expone al cliente. Si `marker` es true se pinta un marker coral 3XA sobre el centro. La imagen se sirve con `Cache-Control: private, max-age=3600`.",
        "parameters": [
          {
            "name": "center",
            "in": "query",
            "required": true,
            "description": "Dirección postal o coordenadas \"lat,lng\" donde centrar el mapa.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "zoom",
            "in": "query",
            "required": false,
            "description": "Nivel de zoom del mapa (1-20).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 20,
              "default": 16
            }
          },
          {
            "name": "size",
            "in": "query",
            "required": false,
            "description": "Tamaño \"WxH\" en píxeles. Máximo efectivo 1280x1280 (se recorta server-side).",
            "schema": {
              "type": "string",
              "pattern": "^\\d{1,4}x\\d{1,4}$",
              "default": "640x320"
            }
          },
          {
            "name": "marker",
            "in": "query",
            "required": false,
            "description": "Si true, añade un marker coral 3XA sobre el centro.",
            "schema": {
              "type": "boolean",
              "default": true
            }
          },
          {
            "name": "scale",
            "in": "query",
            "required": false,
            "description": "Densidad de píxeles de la imagen (2 = retina).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 2,
              "default": 2
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Imagen PNG del mapa estático.",
            "content": {
              "image/png": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Cupo de peticiones a Places superado (`too_many_requests`). Se aplican DOS cupos independientes por ventana fija de 60 s —por usuario y por organización— y basta cruzar uno. Fail-open si Redis no responde.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Error del proveedor Google Static Maps (`places_upstream_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "GOOGLE_MAPS_API_KEY no configurada en el servidor (`places_not_configured`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/overview": {
      "get": {
        "operationId": "platformOverview",
        "tags": [
          "platform"
        ],
        "summary": "Contadores globales de plataforma",
        "description": "Agregados globales (orgs, usuarios, proveedores, errores abiertos, admins).",
        "responses": {
          "200": {
            "description": "Contadores globales.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformOverview"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/search": {
      "get": {
        "operationId": "platformSearch",
        "tags": [
          "platform"
        ],
        "summary": "Búsqueda global del panel (⌘K)",
        "description": "Busca en TODA la plataforma (cross-tenant) organizaciones (nombre/slug), usuarios (nombre/email) y proveedores (nombre) que contengan el término, case-insensitive y literal (sin comodines). Devuelve como mucho 5 resultados por tipo: es la fuente de la paleta de comandos, pensada para saltar a un recurso, no para listar.",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "description": "Término de búsqueda (mínimo 2 caracteres).",
            "schema": {
              "type": "string",
              "minLength": 2
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Resultados agrupados por tipo (listas posiblemente vacías).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformSearchResult"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Término demasiado corto (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/organizations": {
      "get": {
        "operationId": "platformListOrganizations",
        "tags": [
          "platform"
        ],
        "summary": "Listar TODAS las organizaciones",
        "description": "Listado global paginado de organizaciones, con búsqueda por nombre/slug.",
        "parameters": [
          {
            "name": "search",
            "in": "query",
            "required": false,
            "description": "Búsqueda por nombre o slug (case-insensitive, parcial).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filtra por estado de cuenta. Ausente = TODAS (incluidas suspendidas y en papelera), que es el comportamiento histórico: el filtro se añade para poder acotar, no para esconder filas a quien ya las veía.",
            "schema": {
              "type": "string",
              "enum": [
                "active",
                "suspended",
                "deleted"
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Resultados por página (1–200, defecto 50).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Filas a saltar (paginación).",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Organizaciones (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de organizaciones que cumplen los filtros.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformOrganization"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/users": {
      "get": {
        "operationId": "platformListUsers",
        "tags": [
          "platform"
        ],
        "summary": "Listar TODOS los usuarios",
        "description": "Listado global paginado de usuarios, con búsqueda por email/nombre.",
        "parameters": [
          {
            "name": "search",
            "in": "query",
            "required": false,
            "description": "Búsqueda por email o nombre (case-insensitive, parcial).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filtra por estado de cuenta. Ausente = TODOS (incluidos suspendidos y borrados), el comportamiento histórico.",
            "schema": {
              "type": "string",
              "enum": [
                "active",
                "suspended",
                "banned",
                "deleted"
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Resultados por página (1–200, defecto 50).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Filas a saltar (paginación).",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Usuarios (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de usuarios que cumplen los filtros.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformUser"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/users/{user_id}": {
      "parameters": [
        {
          "name": "user_id",
          "in": "path",
          "required": true,
          "description": "UUID del usuario.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "platformGetUser",
        "tags": [
          "platform"
        ],
        "summary": "Detalle de un usuario",
        "description": "Perfil + membresías + passkeys + sesiones activas + dispositivos push.",
        "responses": {
          "200": {
            "description": "Detalle del usuario.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformUserDetail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Usuario inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "platformUpdateUser",
        "tags": [
          "platform"
        ],
        "summary": "Editar campos directos de un usuario",
        "description": "Edición en caliente de campos inocuos (hoy solo `name`) desde el panel ops. El email NO se cambia por aquí: exige re-verificación (`POST /users/{user_id}/email-change`). Parcial: solo se aplica lo enviado. Auditado `platform.user_updated` (before→after).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformUserUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Detalle del usuario ya actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformUserDetail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Usuario inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/users/{user_id}/email-change": {
      "parameters": [
        {
          "name": "user_id",
          "in": "path",
          "required": true,
          "description": "UUID del usuario.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "platformRequestUserEmailChange",
        "tags": [
          "platform"
        ],
        "summary": "Pedir el cambio de email de un usuario (con re-verificación)",
        "description": "Soporte \"me registré con el email mal\": NO cambia el email. Envía al email NUEVO un enlace de verificación firmado (HMAC con APP_SECRET, TTL 24h, single-purpose) que apunta a `GET /api/v1/auth/email-change/confirm`. El email solo cambia al confirmarse; entonces se avisa también al email viejo (anti account-takeover). Auditado `platform.user_email_change_requested`. 409 si el email nuevo ya está en uso al pedirlo (se re-verifica al confirmar). Exige una confirmación de seguridad RECIENTE (step-up fresco): mover el email de acceso es la primitiva de apropiación de cuenta y los avisos existentes son posteriores.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformUserEmailChange"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Correo de verificación encolado; el email sigue sin cambiar.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformEmailChangeRequested"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`) o falta la confirmación de seguridad RECIENTE (`step_up_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Usuario inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "El email nuevo ya está en uso (`email_in_use`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/users/{user_id}/passkeys/{passkey_id}": {
      "parameters": [
        {
          "name": "user_id",
          "in": "path",
          "required": true,
          "description": "UUID del usuario.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "passkey_id",
          "in": "path",
          "required": true,
          "description": "UUID interno de la credencial WebAuthn.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "platformRevokeUserPasskey",
        "tags": [
          "platform"
        ],
        "summary": "Revocar una passkey de un usuario",
        "description": "Elimina la credencial WebAuthn. El usuario deja de poder entrar con ella y la credencial no se recupera (hay que registrar otra). Exige una confirmación de seguridad RECIENTE (step-up fresco).",
        "responses": {
          "204": {
            "description": "Passkey eliminada."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`) o falta la confirmación de seguridad RECIENTE (`step_up_required`): la credencial no se recupera.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Usuario o passkey inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/users/{user_id}/sessions": {
      "parameters": [
        {
          "name": "user_id",
          "in": "path",
          "required": true,
          "description": "UUID del usuario.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "platformRevokeUserSessions",
        "tags": [
          "platform"
        ],
        "summary": "Revocar TODAS las sesiones de un usuario",
        "description": "Revoca todos los refresh tokens activos del usuario (logout remoto en todos sus dispositivos). Sus access tokens vigentes caducan solos en ≤15 min.",
        "responses": {
          "204": {
            "description": "Sesiones revocadas (idempotente)."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Usuario inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/users/{user_id}/push-devices/{device_id}": {
      "parameters": [
        {
          "name": "user_id",
          "in": "path",
          "required": true,
          "description": "UUID del usuario.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "device_id",
          "in": "path",
          "required": true,
          "description": "UUID del registro push (PushSubscription o DeviceToken).",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "kind",
          "in": "query",
          "required": true,
          "description": "Canal del registro a borrar.",
          "schema": {
            "type": "string",
            "enum": [
              "web",
              "native"
            ]
          }
        }
      ],
      "delete": {
        "operationId": "platformDeleteUserPushDevice",
        "tags": [
          "platform"
        ],
        "summary": "Borrar un dispositivo push de un usuario",
        "description": "Elimina la suscripción Web Push o el token nativo indicado.",
        "responses": {
          "204": {
            "description": "Dispositivo eliminado."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Usuario o dispositivo inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/users/{user_id}/2fa/disable": {
      "parameters": [
        {
          "name": "user_id",
          "in": "path",
          "required": true,
          "description": "UUID del usuario.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "platformDisableUserTwoFactor",
        "tags": [
          "platform"
        ],
        "summary": "Desactivar el 2FA (TOTP) de un usuario",
        "description": "Elimina el secreto TOTP y los códigos de recuperación del usuario (rescate de cuenta: móvil perdido). Idempotente: si el usuario no tiene 2FA activo devuelve `disabled: false` sin error. El usuario recibe un email avisando de que soporte ha desactivado su segundo factor. Exige una confirmación de seguridad RECIENTE (step-up fresco): quitar el segundo factor baja el listón de la cuenta a lo que haya en su correo.",
        "responses": {
          "200": {
            "description": "Resultado de la operación (idempotente).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformTwoFactorDisabled"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`) o falta la confirmación de seguridad RECIENTE (`step_up_required`): el secreto TOTP y los códigos de recuperación no se recuperan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Usuario inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/users/{user_id}/resend-login-code": {
      "parameters": [
        {
          "name": "user_id",
          "in": "path",
          "required": true,
          "description": "UUID del usuario.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "platformResendUserLoginCode",
        "tags": [
          "platform"
        ],
        "summary": "Reenviar el código de acceso a un usuario",
        "description": "Emite y envía por email el mismo código de acceso de 6 dígitos del login sin contraseña (invalida cualquier código vivo anterior). Pensado para rescate de cuenta cuando el usuario no puede entrar de ninguna otra forma.",
        "responses": {
          "202": {
            "description": "Código encolado para envío por email.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformLoginCodeResent"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Usuario inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/users/{user_id}/erase": {
      "parameters": [
        {
          "name": "user_id",
          "in": "path",
          "required": true,
          "description": "UUID del usuario.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "platformEraseUser",
        "tags": [
          "platform"
        ],
        "summary": "Borrado GDPR de un usuario (anonimización irreversible)",
        "description": "Ejecuta el derecho de supresión (art. 17 RGPD) sobre un usuario: anonimiza `name`/`email`/`avatar_url`, inutiliza la contraseña, desactiva el 2FA, borra passkeys y registros push, revoca todas las sesiones (refresh tokens) y elimina sus membresías. Sus tareas y comentarios NO se tocan: quedan atribuidos al usuario anonimizado (integridad referencial). Bloqueos con 409: `user_is_platform_admin` (quítalo de la allowlist antes), `user_is_sole_owner` (transfiere la propiedad de sus orgs antes) y `user_already_erased` (segundo intento). Exige `confirm` = «ELIMINAR» (si no, 422 `confirmation_required`) y `reason` no vacío (si no, 422 `validation_error`). Auditado `platform.user_erased` con el informe completo en el payload y el motivo en la columna `reason`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformUserErase"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Usuario anonimizado; informe de lo borrado/revocado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErasureReport"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`) o falta la confirmación de seguridad RECIENTE (`step_up_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Usuario inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Bloqueado: `user_is_platform_admin`, `user_is_sole_owner` o `user_already_erased`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Confirmación incorrecta (`confirmation_required`) o `reason` ausente o vacío (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/suppliers": {
      "get": {
        "operationId": "platformListSuppliers",
        "tags": [
          "platform"
        ],
        "summary": "Listar proveedores de TODAS las organizaciones",
        "description": "Listado cross-org paginado de proveedores, filtrable por organización y nombre.",
        "parameters": [
          {
            "name": "organization_id",
            "in": "query",
            "required": false,
            "description": "Filtrar por organización.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "description": "Búsqueda por nombre (case-insensitive, parcial).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Resultados por página (1–200, defecto 50).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Filas a saltar (paginación).",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Proveedores (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de proveedores que cumplen los filtros.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformSupplier"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "platformCreateSupplier",
        "tags": [
          "platform"
        ],
        "summary": "Crear proveedor en cualquier organización",
        "description": "Alta de proveedor indicando la organización destino. Reusa el modelo org-scoped de `suppliers`: el proveedor creado es visible en la web normal.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformSupplierCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Proveedor creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformSupplier"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización destino inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Datos inválidos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/suppliers/{supplier_id}": {
      "parameters": [
        {
          "name": "supplier_id",
          "in": "path",
          "required": true,
          "description": "UUID del proveedor (org-scoped).",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "platformUpdateSupplier",
        "tags": [
          "platform"
        ],
        "summary": "Editar un proveedor org-scoped",
        "description": "Corrige los datos de un proveedor de cualquier organización (PJKT-2042). PATCH parcial; la organización del proveedor no se puede cambiar.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformSupplierUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Proveedor actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformSupplier"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proveedor inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Datos inválidos (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/errors": {
      "get": {
        "operationId": "platformListErrors",
        "tags": [
          "platform"
        ],
        "summary": "Listar errores capturados",
        "description": "Errores 5xx/excepciones persistidos por el middleware, más recientes primero.",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filtro de estado (defecto `open`).",
            "schema": {
              "type": "string",
              "enum": [
                "open",
                "resolved",
                "all"
              ],
              "default": "open"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Resultados por página (1–200, defecto 50).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Filas a saltar (paginación).",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Errores (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de errores que cumplen los filtros.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ErrorEvent"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/errors/groups": {
      "get": {
        "operationId": "platformListErrorGroups",
        "tags": [
          "platform"
        ],
        "summary": "Listar errores agrupados por firma",
        "description": "Vista agrupada de los errores capturados: una fila por firma (exception_type + path) con contadores y primera/última ocurrencia, última ocurrencia primero.",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filtro de estado (defecto `open`): `open` = firmas con alguna ocurrencia abierta; `resolved` = firmas con todo resuelto.",
            "schema": {
              "type": "string",
              "enum": [
                "open",
                "resolved",
                "all"
              ],
              "default": "open"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Resultados por página (1–200, defecto 50).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Filas a saltar (paginación).",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Grupos de errores (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de firmas que cumplen los filtros.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ErrorGroup"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/errors/groups/resolve": {
      "post": {
        "operationId": "platformResolveErrorGroup",
        "tags": [
          "platform"
        ],
        "summary": "Resolver todas las ocurrencias abiertas de una firma",
        "description": "Marca resueltas TODAS las ocurrencias abiertas de la firma (exception_type + path). Idempotente: si no queda ninguna abierta, devuelve `updated: 0`. Auditado.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ErrorGroupResolve"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Ocurrencias actualizadas.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorGroupResolveResult"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/errors/{error_id}": {
      "parameters": [
        {
          "name": "error_id",
          "in": "path",
          "required": true,
          "description": "UUID del error capturado.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "platformUpdateError",
        "tags": [
          "platform"
        ],
        "summary": "Marcar error resuelto / reabrir",
        "description": "Cambia el estado de resolución del error.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ErrorEventUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Error actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEvent"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Error inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/errors/{error_id}/issue": {
      "parameters": [
        {
          "name": "error_id",
          "in": "path",
          "required": true,
          "description": "UUID del error capturado.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "platformCreateErrorIssue",
        "tags": [
          "platform"
        ],
        "summary": "Crear una issue en Projekt a partir del error",
        "description": "Convierte el error en una tarea del proyecto interno de mantenimiento (PJKT-2043), con el contexto del error en la descripción. El destino se configura con `PLATFORM_ISSUES_ORG_ID`/`PLATFORM_ISSUES_PROJECT_ID`; sin configurar responde 503 `error_issues_disabled`. Idempotente: si el error ya tiene issue, devuelve la existente con `created=false`.",
        "responses": {
          "201": {
            "description": "Issue creada (o ya existente, con `created=false`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformErrorIssue"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Error inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Destino sin configurar (`error_issues_disabled`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/audit": {
      "get": {
        "operationId": "platformListAudit",
        "tags": [
          "platform"
        ],
        "summary": "Leer el audit log global",
        "description": "Lectura paginada del audit log interno (acciones de auth, membresías, api keys, proyectos…), más recientes primero. Solo lectura.",
        "parameters": [
          {
            "name": "action",
            "in": "query",
            "required": false,
            "description": "Filtrar por código de acción exacto (`auth.login`…).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "organization_id",
            "in": "query",
            "required": false,
            "description": "Filtrar por organización.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "user_id",
            "in": "query",
            "required": false,
            "description": "Filtrar por el actor (alias de `actor_id`; se mantiene por compatibilidad).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "actor_id",
            "in": "query",
            "required": false,
            "description": "Filtrar por el usuario que ejecutó la acción (el actor).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "target_id",
            "in": "query",
            "required": false,
            "description": "Filtrar por el recurso afectado por la acción (el target).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "date_from",
            "in": "query",
            "required": false,
            "description": "`created_at` >= (fecha-hora ISO 8601, inclusive).",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "date_to",
            "in": "query",
            "required": false,
            "description": "`created_at` <= (fecha-hora ISO 8601, inclusive).",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Resultados por página (1–200, defecto 50).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Filas a saltar (paginación).",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Entradas de audit (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de entradas que cumplen los filtros.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/AuditLogEntry"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/promos": {
      "get": {
        "operationId": "platformListPromos",
        "tags": [
          "platform"
        ],
        "summary": "Listar códigos promocionales",
        "description": "Códigos promocionales con su beneficio, límites y canjes consumidos (los más nuevos primero). Los emite 3XA: valen para cualquier organización que reciba el enlace, por eso se administran aquí y no bajo una organización.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Códigos promocionales.",
            "headers": {
              "X-Total-Count": {
                "description": "Total de códigos (para paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PromoCodeAdmin"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "platformCreatePromo",
        "tags": [
          "platform"
        ],
        "summary": "Crear un código promocional",
        "description": "Crea el código y devuelve su vista de plataforma. El `code` se normaliza a mayúsculas y es único.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PromoCodeCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Código creado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PromoCodeAdmin"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ya existe un código con ese `code` (`promo_code_taken`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido, o beneficio incoherente con `kind` (`promo_invalid`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/promos/{promo_id}": {
      "parameters": [
        {
          "name": "promo_id",
          "in": "path",
          "required": true,
          "description": "UUID del código promocional.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "platformUpdatePromo",
        "tags": [
          "platform"
        ],
        "summary": "Desactivar o ajustar un código promocional",
        "description": "Cambia `active`, `expires_at` o `max_redemptions`. Lo que el código concede NO se edita: con el enlace repartido, cambiarlo por debajo es peor que crear otro código. Los canjes ya hechos siguen siendo válidos.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PromoCodeUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Código actualizado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PromoCodeAdmin"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Código no encontrado (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/promos/{promo_id}/redemptions": {
      "parameters": [
        {
          "name": "promo_id",
          "in": "path",
          "required": true,
          "description": "UUID del código promocional.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "platformListPromoRedemptions",
        "tags": [
          "platform"
        ],
        "summary": "Historial de canjes de un código promocional",
        "description": "Quién canjeó el código, cuándo y con qué plan (los más recientes primero). El contador `redeemed_count` del código da el total barato; este historial da el detalle para soporte y atribución de campañas.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Canjes del código.",
            "headers": {
              "X-Total-Count": {
                "description": "Total de canjes (para paginar).",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PromoRedemption"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Código no encontrado (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/admins": {
      "get": {
        "operationId": "platformListAdmins",
        "tags": [
          "platform"
        ],
        "summary": "Listar platform admins",
        "description": "Allowlist actual de administradores de plataforma.",
        "responses": {
          "200": {
            "description": "Administradores de plataforma.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformAdmin"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "platformCreateAdmin",
        "tags": [
          "platform"
        ],
        "summary": "Añadir platform admin",
        "description": "Añade a la allowlist un usuario EXISTENTE cuyo email pertenezca a un dominio permitido. Idempotente si ya era admin.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformAdminCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Admin añadido (o ya existente).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformAdmin"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin, o el email no es de un dominio permitido (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No existe usuario con ese email (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Email inválido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/admins/sessions": {
      "get": {
        "operationId": "platformListAdminSessions",
        "tags": [
          "platform"
        ],
        "summary": "Sesiones vivas de los platform admins",
        "description": "Sesiones activas (refresh tokens no revocados ni caducados) de TODOS los miembros de la allowlist de platform admins, con IP, User-Agent y última actividad. La sesión con la que se hace la petición viene marcada con `is_current=true`. Solo admins de la allowlist: los usuarios normales no aparecen aunque tengan sesiones vivas.",
        "responses": {
          "200": {
            "description": "Sesiones vivas de los admins (posiblemente vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformAdminSession"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/admins/{user_id}": {
      "parameters": [
        {
          "name": "user_id",
          "in": "path",
          "required": true,
          "description": "UUID del usuario admin.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "platformDeleteAdmin",
        "tags": [
          "platform"
        ],
        "summary": "Quitar platform admin",
        "description": "Elimina al usuario de la allowlist. Un admin NO puede eliminarse a sí mismo (`cannot_remove_self`) — evita dejar la plataforma sin admins.",
        "responses": {
          "204": {
            "description": "Admin eliminado."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Ese usuario no está en la allowlist (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Intento de auto-eliminación (`cannot_remove_self`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/organizations/{org_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "platformGetOrganization",
        "tags": [
          "platform"
        ],
        "summary": "Detalle modo dios de una organización",
        "description": "Miembros con avatar/rol + contadores (proyectos, tareas por estado, facturas, docs, proveedores) + última actividad.",
        "responses": {
          "200": {
            "description": "Detalle de la organización.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformOrganizationDetail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "platformUpdateOrganization",
        "tags": [
          "platform"
        ],
        "summary": "Editar la ficha de una organización",
        "description": "PATCH parcial (PJKT-2032): nombre y datos fiscales (razón social, NIF/CIF, domicilio fiscal) sin entrar como miembro de la org. Solo cambian los campos presentes; `null` limpia los anulables. El `slug` NO se toca (identificador público). Auditado `platform.org_updated` con before→after por campo.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformOrganizationUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Ficha actualizada (mismo shape que el GET del detalle).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformOrganizationDetail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Body inválido (p. ej. `name` vacío o `null`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "platformSoftDeleteOrganization",
        "tags": [
          "platform"
        ],
        "summary": "Soft-delete de una organización (single-org)",
        "description": "Marca la organización como eliminada (`deleted_at`) SIN borrar nada físico (reversible). Complementa el bulk de W3 (single-org) y arranca el periodo de gracia de cara a una purga posterior. Idempotente: si ya estaba soft-deleted, no-op. Auditado `platform.org_soft_deleted`.",
        "responses": {
          "204": {
            "description": "Organización marcada como eliminada (o ya lo estaba)."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/organizations/{org_id}/projects": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "platformListOrgProjects",
        "tags": [
          "platform"
        ],
        "summary": "Proyectos de una organización (inspección cross-tenant)",
        "description": "Lista N1 de proyectos de la organización para diagnóstico desde el panel: clave, nombre, estado, visibilidad, tareas, miembros explícitos, última actividad y papelera. Solo lectura — el panel observa, no gestiona. La consulta se AUDITA (`platform.org_projects_listed`, una fila por consulta y no por página).",
        "parameters": [
          {
            "name": "include_deleted",
            "in": "query",
            "required": false,
            "description": "Incluir los proyectos en la papelera. Opt-in a propósito: el listado por defecto tiene que cuadrar con el cupo del plan, que no cuenta la papelera.",
            "schema": {
              "type": "boolean",
              "default": false
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Proyectos (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de proyectos de la organización.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformProject"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/organizations/{org_id}/projects/{project_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "platformGetOrgProject",
        "tags": [
          "platform"
        ],
        "summary": "Detalle de un proyecto (inspección cross-tenant)",
        "description": "Detalle N1 de un proyecto: metadatos, contadores por estado de tarea, miembros explícitos y asociaciones (departamento, cliente, carpeta). Sin descripción — eso es contenido del cliente. Se AUDITA (`platform.project_viewed`).",
        "responses": {
          "200": {
            "description": "Detalle del proyecto.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformProjectDetail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización o proyecto inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "platformDeleteOrgProject",
        "tags": [
          "platform"
        ],
        "summary": "Mandar un proyecto del cliente a SU papelera (soft-delete)",
        "description": "Borrado SUAVE de un proyecto ajeno desde el panel: fija `deleted_at` y lo deja en la MISMA papelera que el cliente ve en su producto (`GET /organizations/{org_id}/projects/trash`), con las MISMAS consecuencias que si lo hubiera borrado él (deja de ocupar cupo del plan, sus tareas dejan de aparecer). No se abre un camino de borrado paralelo: se reutiliza el del producto y se le añaden las salvaguardas del panel — sesión interactiva de navegador (implícita en el gate de `/platform/*`), confirmación de identidad (step-up, 403 `step_up_required` sin ella), `confirm_name` tecleado y `reason` obligatorio. Auditado `platform.project_soft_deleted` con actor, IP, agente, organización, proyecto y motivo. IDEMPOTENTE: si ya estaba en la papelera responde 200 con `skipped=true` sin re-auditar. La purga FÍSICA de un proyecto NO se ofrece desde ops: la vuelta atrás tiene que ser más fácil que la acción.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformProjectDelete"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Proyecto en la papelera del cliente (o ya estaba).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformProjectDeletion"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`) o falta la confirmación de identidad (`step_up_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización o proyecto inexistente (`not_found`). También cuando el proyecto existe pero es de OTRA organización: el recurso se resuelve por (proyecto, organización), nunca por nombre.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`confirm_name` no coincide con el nombre del proyecto (`confirm_name_mismatch`) o cuerpo inválido (sin motivo).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/organizations/{org_id}/projects/{project_id}/restore": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "project_id",
          "in": "path",
          "required": true,
          "description": "UUID del proyecto a restaurar.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "platformRestoreOrgProject",
        "tags": [
          "platform"
        ],
        "summary": "Restaurar un proyecto de la papelera del cliente",
        "description": "Saca el proyecto de la papelera (`deleted_at` → NULL). SIN step-up y sin type-to-confirm a propósito: es la operación segura, y deshacer tiene que ser más fácil que borrar. Espejo del restore de organizaciones. DIFERENCIA DELIBERADA con el restore del producto: este NO aplica el límite de proyectos del plan. Un proyecto que ops mandó a la papelera debe poder volver aunque el cliente haya ocupado la plaza que quedó libre; si no, el «soft-delete reversible» sería reversible solo mientras nadie use el hueco. El bypass queda registrado en la auditoría (`platform.project_restored`, `plan_limit_bypassed`). NO es un camino para saltarse el cupo: solo lo pueden llamar platform admins y no crea proyectos, solo revive uno que ya existía.",
        "responses": {
          "200": {
            "description": "Proyecto restaurado (vivo de nuevo).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformProjectDetail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización o proyecto inexistente, o el proyecto NO está en la papelera (`not_found`): no hay nada que restaurar.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/organizations/{org_id}/tasks": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "platformListOrgTasks",
        "tags": [
          "platform"
        ],
        "summary": "Tareas de una organización (inspección cross-tenant)",
        "description": "Tareas de la organización, filtrables por proyecto, estado y título. Proyección N1: metadatos y contadores, NUNCA descripción ni comentarios (eso es `/tasks/{task_id}/content`, que exige motivo). La consulta se AUDITA (`platform.org_tasks_listed`); del término de búsqueda solo se guarda una huella, no el texto.",
        "parameters": [
          {
            "name": "project_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "description": "Búsqueda por título (case-insensitive, parcial).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Tareas (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de tareas que cumplen los filtros.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformTask"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/organizations/{org_id}/tasks/{task_id}": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "description": "UUID de la tarea.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "platformGetOrgTask",
        "tags": [
          "platform"
        ],
        "summary": "Detalle de una tarea (inspección cross-tenant)",
        "description": "Detalle N1 de una tarea + historial de estados. Sin descripción ni comentarios: para eso está `/content`, que exige motivo. Se AUDITA (`platform.task_viewed`).",
        "responses": {
          "200": {
            "description": "Detalle de la tarea.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformTaskDetail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización o tarea inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/organizations/{org_id}/tasks/{task_id}/content": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "task_id",
          "in": "path",
          "required": true,
          "description": "UUID de la tarea.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "platformGetOrgTaskContent",
        "tags": [
          "platform"
        ],
        "summary": "Contenido de una tarea (N2, exige motivo)",
        "description": "Descripción, comentarios y nombres de adjunto de una tarea: CONTENIDO escrito por el cliente. Exige la cabecera `X-Access-Reason` y escribe una fila de auditoría por acceso (`platform.task_content_viewed`), con el motivo en su columna. Sin motivo se responde 403 `access_reason_required` (código propio para que la interfaz pueda pedirlo en un diálogo en vez de enseñar un «no tienes permiso» que miente) y no se sirve nada. Los BYTES de los adjuntos no se descargan nunca desde aquí.",
        "parameters": [
          {
            "name": "X-Access-Reason",
            "in": "header",
            "required": true,
            "description": "Motivo del acceso (10–255 caracteres). Va en cabecera y no en la query string a propósito: un motivo real nombra personas y casos, y las URLs acaban copiadas en logs de acceso, proxy, historial y trazas de error.",
            "schema": {
              "type": "string",
              "minLength": 10,
              "maxLength": 255
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Contenido de la tarea.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformTaskContent"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`) o falta el motivo (`access_reason_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización o tarea inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "El motivo no sirve: demasiado corto o más largo que la columna (`invalid_access_reason`). Se rechaza en vez de recortarlo — media justificación es una justificación falsa.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/stats/timeseries": {
      "get": {
        "operationId": "platformTimeseries",
        "tags": [
          "platform"
        ],
        "summary": "Series temporales diarias del dashboard",
        "description": "Altas de usuarios/orgs, logins (audit) y errores por día natural (UTC), más antiguos primero.",
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "required": false,
            "description": "Ventana en días (7–90, defecto 30).",
            "schema": {
              "type": "integer",
              "minimum": 7,
              "maximum": 90,
              "default": 30
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Un punto por día de la ventana.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformTimeseriesPoint"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/usage": {
      "get": {
        "operationId": "platformUsage",
        "tags": [
          "platform"
        ],
        "summary": "Consumo por organización",
        "description": "Agregados de consumo por organización (cross-tenant, solo lectura): bytes de adjuntos, tareas, documentos, adjuntos y miembros. Orden DESCENDENTE por el campo pedido en `order_by`. `storage_bytes` mide solo lo registrado en las tablas de adjuntos; los avatares/logos legacy almacenados fuera de ellas no cuentan.",
        "parameters": [
          {
            "name": "order_by",
            "in": "query",
            "required": false,
            "description": "Campo por el que ordenar (descendente).",
            "schema": {
              "type": "string",
              "enum": [
                "storage",
                "tasks",
                "members"
              ],
              "default": "storage"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Consumo por organización (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de organizaciones.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformOrgUsage"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`order_by`, `limit` u `offset` fuera de rango (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/health": {
      "get": {
        "operationId": "platformHealth",
        "tags": [
          "platform"
        ],
        "summary": "Salud agregada de la infraestructura",
        "description": "Observabilidad READ-ONLY para operaciones: colas de trabajos (backlog + dead-letter), heartbeat del scheduler periódico, base de datos (latencia + pool), Redis (PING + memoria/clientes) y tasa de errores (1h/24h). El agregado se cachea unos segundos para no martillear Redis. La respuesta NUNCA contiene DSN, URLs con credenciales ni secretos.",
        "responses": {
          "200": {
            "description": "Estado agregado de la infraestructura.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformHealth"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/health/queues": {
      "get": {
        "operationId": "platformHealthQueues",
        "tags": [
          "platform"
        ],
        "summary": "Profundidad de las colas de trabajos",
        "description": "Backlog y dead-letter por cola Dramatiq (LLEN/ZCARD sobre Redis). Vista enfocada de las colas, sin el resto del agregado de salud.",
        "responses": {
          "200": {
            "description": "Profundidad por cola.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformQueueDepth"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/jobs": {
      "get": {
        "operationId": "platformListJobs",
        "tags": [
          "platform"
        ],
        "summary": "Estado de los jobs programados",
        "description": "Estado de cada job periódico del scheduler (PJKT-2029): fusiona el registro declarativo del código (nombre + cadencia) con el último latido sellado en Redis (última ejecución, duración, resultado). Un job sin latido aparece igualmente, con los campos de ejecución a null — «nunca corrió» también es una señal.",
        "responses": {
          "200": {
            "description": "Un elemento por job programado, en el orden del registro.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformJobStatus"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/queues": {
      "get": {
        "operationId": "platformListQueues",
        "tags": [
          "platform"
        ],
        "summary": "Estado de las colas de trabajos Dramatiq",
        "description": "Estado por cola del broker Dramatiq sobre Redis: backlog listo para consumir (`depth`, LLEN de `dramatiq:<cola>`), mensajes diferidos (`delayed`, LLEN de `dramatiq:<cola>.DQ`) y dead-letter (`dead`, ZCARD de `dramatiq:<cola>.XQ`). A diferencia del agregado de salud, esta vista NO degrada en silencio: si Redis no responde devuelve `503 service_unavailable`, porque un panel de colas que enseña ceros con el broker caído es peor que ninguno.",
        "responses": {
          "200": {
            "description": "Estado por cola.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/QueueStatus"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Redis no responde (`service_unavailable`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/global-suppliers": {
      "get": {
        "operationId": "platformListGlobalSuppliers",
        "tags": [
          "platform"
        ],
        "summary": "Listar el catálogo global de proveedores",
        "description": "Catálogo global curado, con contador de proveedores org vinculados.",
        "parameters": [
          {
            "name": "search",
            "in": "query",
            "required": false,
            "description": "Búsqueda por nombre o tax_id (parcial, case-insensitive).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Entradas del catálogo (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de entradas que cumplen los filtros.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/GlobalSupplier"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "platformCreateGlobalSupplier",
        "tags": [
          "platform"
        ],
        "summary": "Crear entrada del catálogo global",
        "description": "Alta manual en el catálogo. Dedupe por tax_id (409 si ya existe).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/GlobalSupplierWrite"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Entrada creada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GlobalSupplier"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ya existe una entrada con ese tax_id (`duplicate_tax_id`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Datos inválidos.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/global-suppliers/import": {
      "post": {
        "operationId": "platformImportGlobalSuppliers",
        "tags": [
          "platform"
        ],
        "summary": "Import masivo CSV del catálogo global",
        "description": "CSV UTF-8 con cabecera. Columnas: `name` (obligatoria), `tax_id`, `email`, `phone`, `address`, `website`, `logo_url`, `notes`. Máx. 2 MB y 5000 filas. Dedupe: match por `tax_id` (preferente) o nombre normalizado → actualiza campos vacíos; sin match → crea.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "Fichero CSV."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Informe del import.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GlobalSupplierImportResult"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Fichero inválido (no CSV, demasiado grande, sin columna name).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/global-suppliers/{global_supplier_id}": {
      "parameters": [
        {
          "name": "global_supplier_id",
          "in": "path",
          "required": true,
          "description": "UUID de la entrada del catálogo.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "patch": {
        "operationId": "platformUpdateGlobalSupplier",
        "tags": [
          "platform"
        ],
        "summary": "Editar entrada del catálogo global",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/GlobalSupplierWrite"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Entrada actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GlobalSupplier"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Entrada inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "tax_id en conflicto con otra entrada (`duplicate_tax_id`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "platformDeleteGlobalSupplier",
        "tags": [
          "platform"
        ],
        "summary": "Borrar entrada del catálogo global",
        "description": "Los proveedores org vinculados conservan sus datos (el vínculo queda a null).",
        "responses": {
          "204": {
            "description": "Entrada borrada."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Entrada inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/suppliers/{supplier_id}/promote": {
      "parameters": [
        {
          "name": "supplier_id",
          "in": "path",
          "required": true,
          "description": "UUID del proveedor org-scoped a promover.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "platformPromoteSupplier",
        "tags": [
          "platform"
        ],
        "summary": "Promover un proveedor org al catálogo global",
        "description": "Copia los datos del proveedor de una organización al catálogo global y vincula el original. Idempotente si ya estaba vinculado. Dedupe por tax_id.",
        "responses": {
          "201": {
            "description": "Entrada global creada (o ya existente y vinculada).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GlobalSupplier"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Proveedor inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/users/{user_id}/sessions/{session_id}": {
      "parameters": [
        {
          "name": "user_id",
          "in": "path",
          "required": true,
          "description": "UUID del usuario.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        },
        {
          "name": "session_id",
          "in": "path",
          "required": true,
          "description": "UUID del refresh token (sesión) a revocar.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "platformRevokeUserSession",
        "tags": [
          "platform"
        ],
        "summary": "Revocar UNA sesión concreta de un usuario",
        "description": "Revoca ese refresh token; el resto de sesiones del usuario siguen vivas.",
        "responses": {
          "204": {
            "description": "Sesión revocada."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Usuario o sesión inexistente/ya revocada (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/users/bulk": {
      "post": {
        "operationId": "platformBulkUsers",
        "tags": [
          "platform"
        ],
        "summary": "Acción en lote sobre usuarios",
        "description": "Aplica una acción destructiva (suspend/unsuspend/soft_delete) a hasta 100 usuarios, cross-tenant. `soft_delete` exige `confirm: true` (si no, 422 `confirmation_required`). Nunca actúa sobre el propio actor ni sobre otro platform admin (esos ids van a `errors` con motivo `self`/`platform_admin`). Resultado honesto: `processed`, `skipped` (ya en el estado destino) y `errors` por id. Cada cambio real queda auditado. Exige confirmación de seguridad (step-up): sudo para suspend/unsuspend y RECIENTE para `soft_delete`, que es irreversible (anonimiza el email y no hay papelera de usuarios). El lote nunca pide menos que la acción individual equivalente.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformUserBulkAction"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Resultado por-elemento del lote.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BulkActionResult"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`) o falta la confirmación de seguridad (`step_up_required`): sudo para suspend/unsuspend y RECIENTE para `soft_delete` (anonimiza el email, sin papelera).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido o falta confirmación (`confirmation_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/organizations/bulk": {
      "post": {
        "operationId": "platformBulkOrganizations",
        "tags": [
          "platform"
        ],
        "summary": "Acción en lote sobre organizaciones",
        "description": "Aplica una acción (suspend/unsuspend/soft_delete/set_plan) a hasta 100 organizaciones, cross-tenant. `soft_delete` exige `confirm: true` (si no, 422 `confirmation_required`). `set_plan` otorga un plan a mano (override manual, reversible): requiere `plan` (si falta, 422 `validation_error`) y salta las orgs con suscripción de Stripe activa (`errors` con motivo `managed_by_stripe`). Resultado honesto: `processed`, `skipped` (ya en el estado/plan destino) y `errors` por id. Cada cambio real queda auditado. Exige confirmación de seguridad (step-up sudo): el mismo nivel que borrar UNA organización desde su ficha, porque el lote destruye más, no menos.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformOrgBulkAction"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Resultado por-elemento del lote.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BulkActionResult"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`) o falta la confirmación de seguridad (`step_up_required`), el mismo nivel (sudo) que el borrado de UNA organización desde su ficha.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido o falta confirmación (`confirmation_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/suppliers/bulk": {
      "post": {
        "operationId": "platformBulkSuppliers",
        "tags": [
          "platform"
        ],
        "summary": "Acción en lote sobre proveedores",
        "description": "Borra o fusiona hasta 100 proveedores org-scoped, cross-tenant. `delete` y `merge` exigen `confirm: true` (si no, 422 `confirmation_required`). En `merge`, `target_id` es obligatorio y debe ser de la misma organización que cada fuente (si no, `errors` con motivo `merge_cross_org`): repunta gastos y facturas de proveedor al destino y borra la fuente. Resultado honesto: `processed`, `skipped` y `errors` por id. Cada cambio real queda auditado. Exige confirmación de seguridad (step-up): sudo para `merge`/`promote` y RECIENTE para `delete`, que borra FÍSICAMENTE las filas (mismo nivel que borrar una entrada del catálogo global, que destruye menos).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformSupplierBulkAction"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Resultado por-elemento del lote.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BulkActionResult"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`) o falta la confirmación de seguridad (`step_up_required`): sudo para `merge`/`promote` y RECIENTE para `delete` (borrado físico de filas).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido o falta confirmación (`confirmation_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/organizations/{org_id}/subscription": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "platformGetOrganizationSubscription",
        "tags": [
          "platform"
        ],
        "summary": "Foto de suscripción/licencia de una organización",
        "description": "Plan efectivo + origen (`plan_source`) + metadatos del override manual (si lo hay) + espejo de Stripe (si existe) + `diverged` (el plan derivado del espejo de Stripe ≠ el efectivo).",
        "responses": {
          "200": {
            "description": "Estado de licencia de la organización.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformSubscriptionDetail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/organizations/{org_id}/subscription/grant": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "platformGrantPlan",
        "tags": [
          "platform"
        ],
        "summary": "Otorgar un plan a mano (override manual)",
        "description": "Otorga un plan a una organización mediante override manual (decisión D2): fija el plan efectivo y `plan_source='manual'`, SIN llamar a Stripe ni cobrar. Mientras el override siga activo, ningún webhook de Stripe lo pisa. `reason` es obligatorio (si falta/vacío, 422 `validation_error`). Idempotente: re-otorgar actualiza el override. Devuelve la foto actualizada.\n\nSi la org tiene una suscripción de Stripe viva se rechaza con 409 `managed_by_stripe` salvo `force: true` (F8, decisión D2e — la misma guardia que el camino masivo). Todo otorgamiento avisa por email/push al RESTO de platform admins.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformPlanGrant"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Override aplicado; foto de suscripción actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformSubscriptionDetail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "La org tiene una suscripción de Stripe viva y no se mandó `force` (`managed_by_stripe`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (p. ej. `reason` vacío o `plan` no válido).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "platformRevokePlan",
        "tags": [
          "platform"
        ],
        "summary": "Revocar el override de plan",
        "description": "Revoca el override manual y recomputa el plan efectivo desde el espejo de Stripe: si la suscripción está activa → su plan (`plan_source='stripe'`); si no → `free` (`plan_source='default'`). Idempotente. Devuelve la foto actualizada.",
        "responses": {
          "200": {
            "description": "Override revocado; foto de suscripción actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformSubscriptionDetail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/subscriptions": {
      "get": {
        "operationId": "platformListSubscriptions",
        "tags": [
          "platform"
        ],
        "summary": "Flota de licencias (todas las organizaciones)",
        "description": "Listado cross-tenant paginado de TODAS las organizaciones con su plan efectivo, origen, estado del espejo de Stripe (si existe) y si divergen. Filtros opcionales combinables: `plan` (efectivo), `status` (estado de Stripe), `source` (`plan_source`) y `diverged`.",
        "parameters": [
          {
            "name": "plan",
            "in": "query",
            "required": false,
            "description": "Filtrar por plan EFECTIVO.",
            "schema": {
              "type": "string",
              "enum": [
                "free",
                "equipo",
                "projekt"
              ]
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filtrar por estado del espejo de Stripe (active, canceled, past_due…).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "source",
            "in": "query",
            "required": false,
            "description": "Filtrar por origen del plan efectivo.",
            "schema": {
              "type": "string",
              "enum": [
                "default",
                "stripe",
                "manual"
              ]
            }
          },
          {
            "name": "diverged",
            "in": "query",
            "required": false,
            "description": "Filtrar por divergencia (plan del espejo de Stripe ≠ efectivo).",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Resultados por página (1–200, defecto 50).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Filas a saltar (paginación).",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Flota de licencias (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de organizaciones que cumplen los filtros.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformSubscriptionRow"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/organizations/{org_id}/subscription/sync-from-stripe": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "platformSyncSubscriptionFromStripe",
        "tags": [
          "platform"
        ],
        "summary": "Reconciliar el espejo de suscripción desde Stripe",
        "description": "Recupera la suscripción real de Stripe y reconcilia el espejo local (`subscriptions`): estado, plan (según el price) y fin del periodo en curso. Recomputa el plan efectivo salvo que haya un override manual activo (decisión D2: el override manda y no lo pisa el sync). Útil cuando el webhook se perdió o el espejo quedó desincronizado. Devuelve la foto actualizada.",
        "responses": {
          "200": {
            "description": "Espejo reconciliado; foto de suscripción actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformSubscriptionDetail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente (`not_found`), sin cliente de facturación (`billing_customer_not_found`) o sin suscripción de Stripe que reconciliar (`billing_subscription_not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Pagos no configurados en el servidor (`billing_not_configured`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/organizations/{org_id}/subscription/cancel": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "platformCancelSubscription",
        "tags": [
          "platform"
        ],
        "summary": "Cancelar la suscripción de Stripe de una organización",
        "description": "Cancela la suscripción REAL de Stripe. `at_period_end=true` (defecto) programa la cancelación al final del periodo pagado (Stripe `cancel_at_period_end`; el plan sigue vigente hasta entonces); `at_period_end=false` cancela de inmediato (Stripe elimina la suscripción y el plan efectivo cae a `free`, salvo override manual activo). Refleja el nuevo estado en el espejo local. Devuelve la foto actualizada.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformSubscriptionCancel"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Cancelación aplicada; foto de suscripción actualizada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformSubscriptionDetail"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente (`not_found`), sin cliente de facturación (`billing_customer_not_found`) o sin suscripción de Stripe que cancelar (`billing_subscription_not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Pagos no configurados en el servidor (`billing_not_configured`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/organizations/{org_id}/subscription/resync": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "platformResyncSubscription",
        "tags": [
          "platform"
        ],
        "summary": "Conciliar la divergencia releyendo la suscripción real de Stripe",
        "description": "Relee la suscripción REAL de Stripe (solo lectura: nunca escribe en Stripe) y adopta su estado exactamente igual que lo haría el webhook (misma función de aplicación: estado, plan según el price, fin de periodo y columnas de dinero; en estados terminales de impago el plan efectivo cae a `free`). Respeta el override manual activo (decisión D2). Auditado con `before_plan` → `after_plan`. Devuelve el resumen de la conciliación.",
        "responses": {
          "200": {
            "description": "Conciliación aplicada; resumen before→after.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformSubscriptionResyncResult"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente (`not_found`) o sin customer/suscripción de Stripe que releer (`no_stripe_subscription`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Pagos no configurados en el servidor (`billing_not_configured`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/feature-flags": {
      "get": {
        "operationId": "platformListFeatureFlags",
        "tags": [
          "platform"
        ],
        "summary": "Estado global de feature flags",
        "description": "Estado del kill-switch GLOBAL de cada feature gateable (una fila por feature de FEATURE_MIN_PLAN). `enabled=null` = sin flag global.",
        "responses": {
          "200": {
            "description": "Matriz de features con su override global (si existe).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformFeatureFlag"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "put": {
        "operationId": "platformSetFeatureFlag",
        "tags": [
          "platform"
        ],
        "summary": "Fijar el kill-switch global de una feature",
        "description": "Upsert del flag GLOBAL de una feature (habilita/deshabilita para todas las orgs). Auditado before→after. Idempotente. `key` debe ser una feature de FEATURE_MIN_PLAN (si no, 422 `validation_error`).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformFeatureFlagSet"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Flag global fijado; estado resultante de esa feature.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformFeatureFlag"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (p. ej. `key` desconocida).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/organizations/{org_id}/feature-flags": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "platformListOrgFeatureFlags",
        "tags": [
          "platform"
        ],
        "summary": "Feature flags efectivos de una organización",
        "description": "Estado EFECTIVO por feature para la org, con el desglose de la precedencia (override de org > flag global > default del plan) y su `source`.",
        "responses": {
          "200": {
            "description": "Matriz de features efectivas de la org.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformOrgFeatureFlag"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "put": {
        "operationId": "platformSetOrgFeatureFlag",
        "tags": [
          "platform"
        ],
        "summary": "Fijar el override de una feature para una org",
        "description": "Upsert del override de UNA org para una feature (le da o le quita acceso a la feature sin cambiar su plan). Auditado before→after. Idempotente. `key` debe ser una feature de FEATURE_MIN_PLAN (si no, 422 `validation_error`).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformFeatureFlagSet"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Override fijado; estado efectivo resultante de esa feature.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformOrgFeatureFlag"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Cuerpo inválido (p. ej. `key` desconocida).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/users/{user_id}/impersonation": {
      "parameters": [
        {
          "name": "user_id",
          "in": "path",
          "required": true,
          "description": "UUID del usuario a impersonar.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "platformCreateImpersonation",
        "tags": [
          "platform"
        ],
        "summary": "Abrir impersonation sobre un usuario (solo lectura)",
        "description": "Abre una sesión de impersonation (\"ver como usuario\", SOLO LECTURA) sobre el usuario indicado. `reason` es OBLIGATORIO (forense). PROHIBIDO impersonar a otro platform admin (`cannot_impersonate_admin`, 403). El usuario debe estar activo (`user_not_active`, 422). Si se pasa `organization_id`, el usuario debe ser miembro (`not_a_member`, 422). Devuelve el token OPACO (UNA vez) y la `activation_url` que el panel abre para fijar la cookie en la web. Auditado `platform.impersonation_started`. Bajo impersonation este endpoint queda fuera de alcance (una sesión impersonada no es platform admin → 403).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ImpersonationCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Sesión de impersonation abierta.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ImpersonationCreated"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`) o el objetivo es platform admin (`cannot_impersonate_admin`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Usuario inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Motivo vacío, usuario no activo (`user_not_active`) o no miembro de la org (`not_a_member`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/impersonation": {
      "get": {
        "operationId": "platformListImpersonations",
        "tags": [
          "platform"
        ],
        "summary": "Sesiones de impersonation activas",
        "description": "Lista las sesiones de impersonation VIVAS (no revocadas ni caducadas), más recientes primero. Solo metadata forense (quién, a quién, motivo, caducidad); el token nunca viaja.",
        "responses": {
          "200": {
            "description": "Sesiones de impersonation activas.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ImpersonationSession"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/impersonation/{session_id}": {
      "parameters": [
        {
          "name": "session_id",
          "in": "path",
          "required": true,
          "description": "UUID de la sesión de impersonation.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "operationId": "platformRevokeImpersonation",
        "tags": [
          "platform"
        ],
        "summary": "Revocar una sesión de impersonation",
        "description": "Revoca la sesión de impersonation AL INSTANTE (la cookie del navegador deja de resolver a nada útil en la siguiente petición). Idempotente: revocar una ya revocada es un no-op. Auditado `platform.impersonation_ended` (via=revoke).",
        "responses": {
          "204": {
            "description": "Sesión revocada (o ya lo estaba); sin cuerpo."
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Sesión inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/announcements": {
      "get": {
        "operationId": "platformListAnnouncements",
        "tags": [
          "platform"
        ],
        "summary": "Historial de anuncios de plataforma",
        "description": "Anuncios emitidos, más recientes primero, con su `status` y `recipient_count`.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Resultados por página (1–200, defecto 50).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Filas a saltar (paginación).",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Anuncios (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de anuncios.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Announcement"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "platformCreateAnnouncement",
        "tags": [
          "platform"
        ],
        "summary": "Crear y encolar un anuncio de plataforma",
        "description": "Crea el anuncio (status `queued`) y encola el job Dramatiq que lo abanica sobre el módulo de notificaciones: por cada usuario elegible EMITE una notificación por sus canales (in-app / web push / email) RESPETANDO sus preferencias y bajas. Valida la coherencia de la audiencia (plan exige `audience_plan`; org exige `audience_org_id`) → 422. Auditado `platform.announcement_sent` al terminar el envío.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AnnouncementCreate"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Anuncio creado y encolado para envío.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Announcement"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Audiencia incoherente (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/announcements/recipient-count": {
      "get": {
        "operationId": "platformAnnouncementRecipientCount",
        "tags": [
          "platform"
        ],
        "summary": "Preview del recuento de destinatarios",
        "description": "Resuelve la audiencia y cuenta los usuarios elegibles SIN enviar nada, para confirmar \"se enviará a N usuarios\" antes de disparar. Valida la coherencia de la audiencia (plan exige `audience_plan`; org exige `audience_org_id`) → 422.",
        "parameters": [
          {
            "name": "audience_scope",
            "in": "query",
            "required": true,
            "description": "Segmentación de la audiencia.",
            "schema": {
              "type": "string",
              "enum": [
                "global",
                "plan",
                "org"
              ]
            }
          },
          {
            "name": "audience_plan",
            "in": "query",
            "required": false,
            "description": "Plan objetivo (obligatorio si `audience_scope=plan`).",
            "schema": {
              "type": "string",
              "enum": [
                "free",
                "equipo",
                "projekt"
              ]
            }
          },
          {
            "name": "audience_org_id",
            "in": "query",
            "required": false,
            "description": "Organización objetivo (obligatoria si `audience_scope=org`).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Recuento de destinatarios elegibles.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AnnouncementRecipientCount"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Audiencia incoherente (`validation_error`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/organizations/{org_id}/exports": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "platformListOrgExports",
        "tags": [
          "platform"
        ],
        "summary": "Historial de exports de una organización",
        "description": "Lista los exports de datos de la organización, más recientes primero.",
        "responses": {
          "200": {
            "description": "Exports de la organización.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformExport"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "platformCreateOrgExport",
        "tags": [
          "platform"
        ],
        "summary": "Pedir un export de datos de una organización",
        "description": "Crea el export (status `queued`) y encola el job Dramatiq que serializa TODOS los datos de la org (organización, miembros, proyectos, tareas y registros de tiempo) a JSONL dentro de un ZIP y lo sube por StorageService. Responde 202 con el export recién creado; el estado se consulta con `GET /platform/exports/{export_id}`.",
        "responses": {
          "202": {
            "description": "Export encolado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformExport"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/exports/{export_id}": {
      "parameters": [
        {
          "name": "export_id",
          "in": "path",
          "required": true,
          "description": "UUID del export.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "platformGetExport",
        "tags": [
          "platform"
        ],
        "summary": "Estado de un export",
        "description": "Devuelve el estado del export. Cuando `status=done` incluye `download_url`, la ruta del endpoint de descarga AUTENTICADO (no es una URL pública).",
        "responses": {
          "200": {
            "description": "Estado del export.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformExport"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Export inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/exports/{export_id}/download": {
      "parameters": [
        {
          "name": "export_id",
          "in": "path",
          "required": true,
          "description": "UUID del export.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "platformDownloadExport",
        "tags": [
          "platform"
        ],
        "summary": "Descargar el ZIP de un export",
        "description": "Descarga AUTENTICADA del ZIP del export (requiere sesión de platform admin): hace stream del blob del StorageService. NUNCA es una ruta pública. 404 si el export no existe o todavía no está `done`. Exige confirmación de seguridad (step-up sudo): saca del sistema una copia íntegra de los datos de un cliente y eso no se deshace.",
        "responses": {
          "200": {
            "description": "ZIP del export (un JSONL por entidad de la org).",
            "content": {
              "application/zip": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`) o falta la confirmación de seguridad (`step_up_required`): la copia descargada no se «des-descarga».",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Export inexistente o no listo (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/organizations/{org_id}/purge": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización a purgar.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "platformPurgeOrganization",
        "tags": [
          "platform"
        ],
        "summary": "Purgar una organización (borrado físico IRREVERSIBLE)",
        "description": "Pide la PURGA (borrado físico en cascada, IRREVERSIBLE) de una org YA soft-deleted, validando LAS 5 SALVAGUARDAS: (1) la org está soft-deleted y (2) ha pasado el periodo de gracia — si no, 422 (con el instante en que será elegible); (3) existe un export `done` de la org — si no, 409 `export_required`; (4) guards: el actor no es miembro de la org y ningún platform admin quedaría huérfano de orgs — si no, 403; (5) `confirm_name` coincide EXACTO con el nombre de la org — si no, 422 `confirm_name_mismatch`. Si todas pasan: crea la purga (`queued`), audita `platform.org_purge_requested` y encola el job (que RE-verifica las salvaguardas antes de borrar). Los USUARIOS miembros NUNCA se borran (solo lo org-scoped, vía el CASCADE de la org). 202 con la purga en `queued`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformPurgeRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Purga encolada.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformPurge"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`) o falla un guard (`actor_is_member`, `would_orphan_platform_admin`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Falta un export `done` de la org (`export_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Salvaguarda no cumplida (`organization_not_deleted`, `grace_period_not_elapsed`, `confirm_name_mismatch`) o cuerpo inválido.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/purges": {
      "get": {
        "operationId": "platformListPurges",
        "tags": [
          "platform"
        ],
        "summary": "Historial de purgas de plataforma",
        "description": "Listado paginado de purgas (borrado físico), más recientes primero, con status.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Resultados por página (1–200, defecto 50).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Filas a saltar (paginación).",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Purgas (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de purgas registradas.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformPurge"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/organizations/trash": {
      "get": {
        "operationId": "platformListTrashOrganizations",
        "tags": [
          "platform"
        ],
        "summary": "Papelera de organizaciones (soft-deleted restaurables)",
        "description": "Organizaciones soft-deleted (`deleted_at` fijado), borradas más recientemente primero. Cada fila incluye `purge_eligible_at` (deleted_at + periodo de gracia de la purga W19) para que el panel pinte cuánta gracia queda antes de que la org sea purgable. Mientras la fila exista aquí, restaurar es posible; tras la purga física desaparece.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Resultados por página (1–200, defecto 50).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Filas a saltar (paginación).",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Organizaciones en la papelera (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de organizaciones soft-deleted.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformTrashOrganization"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/organizations/{org_id}/restore": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización a restaurar.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "platformRestoreOrganization",
        "tags": [
          "platform"
        ],
        "summary": "Restaurar una organización de la papelera",
        "description": "Revive una org soft-deleted: `deleted_at` vuelve a NULL y sus miembros recuperan el acceso al instante. NO toca la purga (W19): una purga ya encolada RE-verifica la elegibilidad antes de borrar y aborta al ver la org restaurada. Auditado `platform.org_restored`. 404 si la org existe pero NO está borrada (no hay nada que restaurar); 409 `already_purged` si ya no existe físicamente (la purga se la llevó).",
        "responses": {
          "200": {
            "description": "Organización restaurada (viva de nuevo).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformOrganization"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "La organización no está en la papelera (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Ya no existe físicamente (`already_purged`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/revenue/summary": {
      "get": {
        "operationId": "platformRevenueSummary",
        "tags": [
          "platform"
        ],
        "summary": "Resumen de revenue de la plataforma",
        "description": "MRR/ARR/ARPA + suscripciones activas de toda la plataforma, computado en vivo OFF `subscriptions` (estado `active`/`past_due`, divisa base única). Incluye tendencia (variación %, expansión/contracción neta) derivada de los dos snapshots diarios más recientes y `basis` (`stripe_synced` | `partial` | `list_price_estimate`), que declara si el MRR está completo.\n\nMisma política de estados que la serie temporal desde F7 (una prueba aporta 0): el KPI y su gráfica ya no cuentan cosas distintas. Lo que sí difiere es el INSTANTE — este agregado es de ahora mismo (`as_of`) y la serie es la foto diaria del último barrido.",
        "responses": {
          "200": {
            "description": "Agregado de revenue.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformRevenueSummary"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/revenue/by-plan": {
      "get": {
        "operationId": "platformRevenueByPlan",
        "tags": [
          "platform"
        ],
        "summary": "MRR por plan",
        "description": "Desglose de MRR y suscripciones activas por plan comprado, OFF `subscriptions` (estado que cuenta, divisa base). Invariante: la suma de `mrr_minor` == el `mrr_minor` del resumen.",
        "responses": {
          "200": {
            "description": "Una fila por plan con suscripciones que cuentan (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformRevenueByPlanRow"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/revenue/timeseries": {
      "get": {
        "operationId": "platformRevenueTimeseries",
        "tags": [
          "platform"
        ],
        "summary": "Serie temporal de revenue",
        "description": "Un punto por día natural (UTC) de la ventana que termina hoy, leído de `mrr_daily_snapshots`. Huecos rellenos por carry-forward del último valor conocido (0 antes del primer snapshot); más antiguos primero.\n\nCuenta los MISMOS estados que el resumen en vivo (`active`/`past_due`; las pruebas aportan 0) desde F7. Aviso: los snapshots escritos ANTES de ese cambio sí incluían las suscripciones en prueba, así que el tramo antiguo de la serie puede ir algo por encima; no se reescribe el histórico.",
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "required": false,
            "description": "Ventana en días (7–365, defecto 30).",
            "schema": {
              "type": "integer",
              "minimum": 7,
              "maximum": 365,
              "default": 30
            }
          },
          {
            "name": "metric",
            "in": "query",
            "required": false,
            "description": "Métrica a graficar; `mrr` (mensual) o `revenue` (ARR run-rate = MRR×12).",
            "schema": {
              "type": "string",
              "enum": [
                "mrr",
                "revenue"
              ],
              "default": "mrr"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Un punto por día de la ventana.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformRevenuePoint"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/revenue/subscriptions": {
      "get": {
        "operationId": "platformRevenueSubscriptions",
        "tags": [
          "platform"
        ],
        "summary": "Suscripciones con importe",
        "description": "Listado paginado de las suscripciones de Stripe (espejo local) con su MRR precomputado y `synced`. Filtros opcionales por `status` (estado de Stripe) y `plan`.",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filtrar por estado del espejo de Stripe (active, past_due, canceled…).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "plan",
            "in": "query",
            "required": false,
            "description": "Filtrar por plan comprado.",
            "schema": {
              "type": "string",
              "enum": [
                "free",
                "equipo",
                "projekt"
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Resultados por página (1–200, defecto 50).",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Filas a saltar (paginación).",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Suscripciones con importe (puede ser vacío).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de suscripciones que cumplen los filtros.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformRevenueSubscriptionRow"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/billing/at-risk": {
      "get": {
        "operationId": "platformListBillingAtRisk",
        "tags": [
          "platform"
        ],
        "summary": "Suscripciones con el cobro en riesgo",
        "description": "Lista cross-tenant de suscripciones cuyo estado en el espejo de Stripe es `past_due`, `unpaid` o `incomplete` (cobro fallido o checkout sin completar), con la organización, el email del owner y desde cuándo están en riesgo (más antiguas primero). Lista corta por naturaleza: se devuelve entera, sin paginación.",
        "responses": {
          "200": {
            "description": "Suscripciones en riesgo (puede ser vacía).",
            "headers": {
              "X-Total-Count": {
                "description": "Total de suscripciones en riesgo.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformBillingAtRiskRow"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/billing/at-risk/{org_id}/notify": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización con el cobro en riesgo.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "operationId": "platformNotifyBillingAtRisk",
        "tags": [
          "platform"
        ],
        "summary": "Reenviar el aviso de pago pendiente al owner",
        "description": "Reenvía por email al owner de la organización el aviso de pago pendiente (job en background; la petición no espera al envío). Auditado. Anti-spam: como mucho un aviso cada 24 h por organización (clave Redis con TTL); si ya se avisó dentro de la ventana responde 202 con `sent=false`.",
        "responses": {
          "202": {
            "description": "Resultado del reenvío (`sent=false` = frenado por el anti-spam).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformBillingAtRiskNotifyResult"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o sin espejo de suscripción (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "La suscripción no está en riesgo (`subscription_not_at_risk`) o la organización no tiene owner con email (`owner_not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/metricas-del-modelo": {
      "get": {
        "operationId": "getMetricasDelModelo",
        "tags": [
          "platform"
        ],
        "summary": "Cómo se reparte la gente entre las tres ediciones",
        "description": "El reparto de las tres ediciones, con sus organizaciones, su gente, su MRR y el ingreso por persona.\n\nContesta lo que `/platform/revenue/metrics` NO puede: ahí Free es invisible por construcción —una organización Free no tiene suscripción— y en un modelo cuyo embudo empieza en Free, no ver el Free es no ver el embudo.\n\nLas dos mitades salen de sitios distintos y a propósito: la POBLACIÓN de `organizations` y `memberships`, que es donde vive; el DINERO de `subscriptions`, con el mismo filtro de estado y divisa que usa la pantalla de revenue, para que las dos no den cifras distintas del mismo mes.",
        "responses": {
          "200": {
            "description": "El reparto por edición.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformModeloMetrics"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/platform/revenue/metrics": {
      "get": {
        "operationId": "platformRevenueMetrics",
        "tags": [
          "platform"
        ],
        "summary": "Churn y MRR por mes y por plan",
        "description": "Serie MENSUAL (últimos 12 meses como máximo) de MRR, altas de pago y churn, más el desglose ACTUAL de MRR y orgs activas por plan. Solo datos computables con lo que hay: la serie empieza en el primer mes con evidencia real (primer snapshot de `mrr_daily_snapshots` o primera suscripción registrada) y es vacía si no hay ninguna. LIMITACIONES: churn y altas derivan del estado ACTUAL del espejo `subscriptions` (`canceled_at`/`created_at`, una fila por org), no de un histórico de eventos — ver `PlatformRevenueMonthPoint`.",
        "responses": {
          "200": {
            "description": "Serie mensual + desglose por plan (ambos pueden ser vacíos).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformRevenueMetrics"
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "No es platform admin (`forbidden`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/organizations/{org_id}/global-suppliers": {
      "parameters": [
        {
          "name": "org_id",
          "in": "path",
          "required": true,
          "description": "UUID de la organización.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "listGlobalSuppliersForOrg",
        "tags": [
          "suppliers"
        ],
        "summary": "Buscar en el catálogo global de proveedores",
        "description": "Catálogo global compartido de la plataforma, para autocompletar el alta de proveedores. Solo lectura; requiere ser miembro de la organización.",
        "parameters": [
          {
            "name": "search",
            "in": "query",
            "required": false,
            "description": "Búsqueda por nombre o tax_id (parcial, case-insensitive).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 50,
              "default": 10
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Entradas del catálogo (puede ser vacía).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/GlobalSupplier"
                  }
                }
              }
            }
          },
          "401": {
            "description": "No autenticado.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Organización inexistente o usuario no miembro (`not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "cookieAuth": {
        "type": "apiKey",
        "in": "cookie",
        "name": "access_token",
        "description": "JWT de acceso (15 min) en cookie HttpOnly `access_token`.\nSe emite/rota en register, login y refresh junto con la cookie HttpOnly\n`refresh_token` (token opaco rotativo, hasheado en DB), que solo usa\n`POST /api/v1/auth/refresh`. `SameSite=Lax`; `Secure` según `COOKIE_SECURE`.\n"
      }
    },
    "schemas": {
      "User": {
        "type": "object",
        "title": "User",
        "description": "Usuario de la plataforma.",
        "required": [
          "id",
          "email",
          "name",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Identificador UUID.",
            "examples": [
              "6f1f9c3e-8f2a-4b6e-9a3d-2b1c0d4e5f60"
            ]
          },
          "email": {
            "type": "string",
            "format": "email",
            "examples": [
              "nick@3xa.es"
            ]
          },
          "name": {
            "type": "string",
            "examples": [
              "Nick Valdivia"
            ]
          },
          "avatar_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL del avatar del usuario; `null` si no tiene.",
            "examples": [
              "https://cdn.projekt.3xa.es/avatars/nick.png"
            ]
          },
          "headline": {
            "type": [
              "string",
              "null"
            ],
            "description": "Titular/rol del usuario (una línea corta bajo el nombre en su perfil); `null` si no lo ha rellenado.",
            "examples": [
              "Fundador y CTO en 3XA"
            ]
          },
          "bio": {
            "type": [
              "string",
              "null"
            ],
            "description": "Biografía libre del usuario para su perfil; `null` si vacía.",
            "examples": [
              "Construyendo Projekt. Café, código y kanban."
            ]
          },
          "location": {
            "type": [
              "string",
              "null"
            ],
            "description": "Ubicación del usuario (ciudad/país); `null` si no la ha puesto.",
            "examples": [
              "Madrid, España"
            ]
          },
          "language": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 10,
            "description": "Idioma preferido del usuario en BCP-47 (`es`, `en-US`…). `null` —el caso normal— NO significa castellano: significa «lo que diga mi organización» (`Organization.locale`). Cuando está puesto GANA sobre el de la organización, porque el idioma es de quien LEE: un empleado inglés de una empresa española recibe sus avisos en inglés sin que eso cambie el idioma de las facturas que la empresa emite. Se edita con `PATCH /me/profile`.",
            "examples": [
              "en-US"
            ]
          },
          "weather_override": {
            "type": [
              "string",
              "null"
            ],
            "description": "Condición meteorológica fijada manualmente por el usuario para el widget del dashboard. `null` = live (se consulta Open-Meteo). Valores válidos: `clear`, `clouds`, `rain`, `snow`, `thunder`, `fog`.",
            "enum": [
              "clear",
              "clouds",
              "rain",
              "snow",
              "thunder",
              "fog",
              null
            ],
            "examples": [
              "clear"
            ]
          },
          "last_organization_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Última organización usada por el usuario; el frontend la restaura al abrir (persistencia cross-device). `null` = aún no fijada (o la org se borró).",
            "examples": [
              "6f1f9c3e-8f2a-4b6e-9a3d-2b1c0d4e5f60"
            ]
          },
          "profile_visibility": {
            "type": "string",
            "enum": [
              "private",
              "organization",
              "public"
            ],
            "default": "private",
            "description": "Quién ve tu perfil social (PJKT-1990). Nace en `private` para todas las cuentas, también las anteriores a la función: abrirlo es siempre un acto explícito. Solo aparece en el perfil PROPIO — para ver el de otra persona está `GET /profiles/{user_id}`, que filtra según su privacidad."
          },
          "social_links": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            },
            "description": "Enlaces a otras redes. Claves admitidas: `linkedin`, `x`, `github`, `instagram`, `youtube`, `mastodon`, `website`; solo URLs http(s). El servidor rechaza el resto (`422 unknown_social_network` / `422 invalid_social_url`)."
          },
          "ambient_background": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 40,
            "description": "Clave del shader de fondo de ambiente Pro elegido por el usuario (p.ej. `chroma-waves`). Persistido a nivel de USUARIO (fuente de verdad cross-device). `null` = ningún fondo de ambiente. La taxonomía de shaders la define el frontend; el servidor la trata como una clave opaca. Se edita con `PUT /me/ui-preferences`.",
            "examples": [
              "chroma-waves"
            ]
          },
          "os_preferences": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true,
            "description": "Ajustes del escritorio de esta persona, guardados en servidor (fondo de pantalla, widgets, orden del dock, navmenu compacta, geometría de ventanas por organización). `null` = todavía no ha guardado ninguno: siguen solo en su navegador y el primer cambio los sube. Objeto OPACO — la taxonomía de claves la define el frontend. Se edita con `PUT /me/ui-preferences`, que los fusiona clave a clave.",
            "examples": [
              {
                "projekt.os.fondo": "silk"
              }
            ]
          },
          "card_style": {
            "type": "string",
            "enum": [
              "solid",
              "translucent",
              "glass"
            ],
            "default": "solid",
            "description": "Estilo visual de las cards del producto. Persistido a nivel de USUARIO (fuente de verdad cross-device). Nace en `solid` para todas las cuentas, también las anteriores a la función. Se edita con `PUT /me/ui-preferences`.",
            "examples": [
              "solid"
            ]
          },
          "call_ringtone": {
            "type": "boolean",
            "default": false,
            "description": "Si el aviso de llamada entrante suena además de verse. Persistido a nivel de USUARIO (fuente de verdad cross-device). Nace en `false`: el navegador no deja sonar nada hasta que el usuario ha interactuado con la página, y el gesto que lo desbloquea es justo el clic con el que se activa esta preferencia. El aviso VISUAL de llamada no depende de este campo — el tono es un extra. Se edita con `PUT /me/ui-preferences`.",
            "examples": [
              false
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-08T12:00:00Z"
            ]
          },
          "api_key": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ApiKeyScope"
              },
              {
                "type": "null"
              }
            ],
            "description": "Scope de la API key (PAT) que autenticó la petición. Presente (non-null) cuando la petición usa `Authorization: Bearer pjk_live_…`; `null` cuando la sesión es por cookie. Permite a los API clients (MCP, scripts) descubrir a qué organización y proyecto está acotada la key con la que operan."
          },
          "impersonating": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Impersonating"
              },
              {
                "type": "null"
              }
            ],
            "description": "Contexto de impersonation (panel ops, W16). Presente (non-null) SOLO cuando la petición llega bajo una sesión de impersonation: el `User` devuelto ES el usuario impersonado y este campo indica al frontend que pinte el banner permanente de \"solo lectura\". `null` en una sesión normal. Nunca autoriza nada: el enforcement read-only es server-side."
          }
        }
      },
      "Impersonating": {
        "type": "object",
        "title": "Impersonating",
        "description": "Presente (non-null) solo cuando la petición llega bajo una sesión de impersonation del panel ops: el `User` devuelto ES el usuario impersonado y este objeto indica al frontend que pinte el banner permanente \"Estás viendo como {as_name} — solo lectura\". Nunca autoriza nada (el enforcement read-only es server-side); es solo presentación.",
        "required": [
          "as_name",
          "as_email",
          "expires_at"
        ],
        "properties": {
          "as_name": {
            "type": "string",
            "description": "Nombre del usuario que se está viendo (para el banner)."
          },
          "as_email": {
            "type": "string",
            "format": "email",
            "description": "Email del usuario que se está viendo."
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "Cuándo caduca la sesión de impersonation (para el contador del banner)."
          },
          "read_only": {
            "type": "boolean",
            "default": true,
            "description": "Si la sesión de impersonation es de solo lectura. Hoy TODAS lo son (decisión D3) y el servidor lo fuerza (403 `impersonation_read_only` en cualquier mutación); este flag es solo para que el banner lo etiquete."
          },
          "actor_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nombre del agente de soporte (el impersonator) que abrió la sesión, para que el banner deje claro QUIÉN está mirando. Null si su cuenta ya no existe."
          }
        }
      },
      "MyStats": {
        "type": "object",
        "title": "MyStats",
        "description": "Recuentos agregados del usuario autenticado a través de TODAS las organizaciones a las que pertenece. Solo lectura; pensado para la cabecera de su perfil (`/me`). No está acotado a una organización concreta.",
        "required": [
          "orgs",
          "projects",
          "tasks_assigned",
          "tasks_done"
        ],
        "properties": {
          "orgs": {
            "type": "integer",
            "minimum": 0,
            "description": "Número de organizaciones a las que pertenece el usuario.",
            "examples": [
              3
            ]
          },
          "projects": {
            "type": "integer",
            "minimum": 0,
            "description": "Número de proyectos de esas organizaciones (visibles para el usuario por su pertenencia).",
            "examples": [
              12
            ]
          },
          "tasks_assigned": {
            "type": "integer",
            "minimum": 0,
            "description": "Número de tareas actualmente asignadas al usuario (todas las orgs).",
            "examples": [
              24
            ]
          },
          "tasks_done": {
            "type": "integer",
            "minimum": 0,
            "description": "Número de tareas asignadas al usuario que están completadas (`done`), incluidas en `tasks_assigned`.",
            "examples": [
              15
            ]
          }
        }
      },
      "OnboardingState": {
        "type": "object",
        "title": "OnboardingState",
        "description": "Estado de onboarding del usuario autenticado (diálogo de bienvenida, tours guiados por módulo y progreso del asistente de puesta en marcha), persistido a nivel de USUARIO en el servidor (nunca en la organización). Es la FUENTE DE VERDAD cross-device: el frontend usa localStorage solo como caché optimista. Se lee con `GET /me/onboarding` y se reemplaza por completo con `PUT /me/onboarding` (el mismo shape en ambos sentidos), con UNA excepción: `setup` se preserva si se omite (ver su descripción). Un usuario que nunca ha guardado estado recibe el estado vacío (`welcome_seen=false`, `tours_completed=[]`, `version=0`, `setup=null`).",
        "required": [
          "welcome_seen",
          "tours_completed",
          "version"
        ],
        "properties": {
          "welcome_seen": {
            "type": "boolean",
            "description": "`true` cuando el usuario ya vio (y descartó, empezando el tour o eligiendo explorar por su cuenta) el diálogo de bienvenida del primer login.",
            "examples": [
              true
            ]
          },
          "tours_completed": {
            "type": "array",
            "description": "Claves de los tours de módulo que el usuario ya completó o descartó (p. ej. `dashboard`, `projects`, `project-detail`, `docs`, `finance`). Cada clave es OPACA para el servidor: la taxonomía de tours la define el frontend, y el servidor solo la persiste y devuelve tal cual. Sin duplicados.",
            "maxItems": 100,
            "items": {
              "type": "string",
              "minLength": 1,
              "maxLength": 64
            },
            "examples": [
              [
                "dashboard",
                "projects"
              ]
            ]
          },
          "version": {
            "type": "integer",
            "minimum": 0,
            "description": "Versión de onboarding con la que se guardó este estado (el `ONBOARDING_VERSION` del frontend en el momento de guardar). El frontend re-muestra welcome + tours cuando su versión actual es MAYOR que la aquí guardada (bump de onboarding tras un rediseño). `0` = estado vacío / nunca guardado.",
            "examples": [
              2
            ]
          },
          "setup": {
            "type": [
              "object",
              "null"
            ],
            "title": "SetupProgress",
            "description": "Progreso del ASISTENTE DE PUESTA EN MARCHA (el guion de primeros pasos que sustituye al diálogo de bienvenida). `null` = el usuario nunca lo arrancó. Sirve para reanudarlo donde se dejó y para no volver a empujar lo que ya hizo o descartó; el estado REAL de la organización (si existe, si tiene logo, proyectos, clientes…) NO se guarda aquí, se deriva de los datos. SEMÁNTICA ESPECIAL EN EL PUT: a diferencia del resto del objeto, si el cuerpo OMITE `setup` el servidor conserva el valor ya guardado (así un cliente antiguo —pestaña vieja, PWA cacheada— que no conoce el asistente no borra su progreso al guardar tours). Para borrarlo hay que enviar explícitamente `setup: null`; para cambiarlo, el objeto completo.",
            "required": [
              "version",
              "status",
              "plan",
              "organization_id",
              "current_step",
              "completed_steps",
              "skipped_steps",
              "answers"
            ],
            "properties": {
              "version": {
                "type": "integer",
                "minimum": 0,
                "description": "Versión del guion del asistente con la que se guardó este progreso. El frontend reinicia el progreso cuando su versión actual es MAYOR (los pasos cambiaron y las claves guardadas ya no significan lo mismo).",
                "examples": [
                  1
                ]
              },
              "status": {
                "type": "string",
                "enum": [
                  "in_progress",
                  "dismissed",
                  "completed"
                ],
                "description": "`in_progress` = arrancado y sin terminar (el dashboard ofrece «Continuar la puesta en marcha»); `dismissed` = el usuario lo cerró a propósito; `completed` = llegó al último paso.",
                "examples": [
                  "in_progress"
                ]
              },
              "plan": {
                "type": [
                  "string",
                  "null"
                ],
                "enum": [
                  "free",
                  "equipo",
                  "projekt",
                  null
                ],
                "description": "Plan efectivo de la organización EN EL MOMENTO de generar el guion (snapshot). Permite detectar un cambio de plan y reaparecer con los bloques recién desbloqueados. `null` si aún no había organización.",
                "examples": [
                  "free"
                ]
              },
              "organization_id": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "uuid",
                "description": "Organización para la que se generó el guion. `null` mientras el paso de crear organización no se ha completado. Si el usuario arranca el asistente para OTRA organización, el frontend reinicia este objeto con el nuevo id (el progreso guardado es siempre el del asistente en curso, uno a la vez)."
              },
              "current_step": {
                "type": [
                  "string",
                  "null"
                ],
                "minLength": 1,
                "maxLength": 64,
                "description": "Clave del paso en el que se quedó, para reanudar. OPACA para el servidor: la taxonomía de pasos la define el frontend. `null` = sin paso en curso (recién arrancado o terminado).",
                "examples": [
                  "brand"
                ]
              },
              "completed_steps": {
                "type": "array",
                "description": "Claves de los pasos que el usuario ya completó. Opacas para el servidor y sin duplicados. Es un registro de INTENCIÓN, no de datos: lo que de verdad existe (org, logo, proyecto, cliente…) se deriva del API.",
                "maxItems": 50,
                "items": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 64
                },
                "examples": [
                  [
                    "org",
                    "project"
                  ]
                ]
              },
              "skipped_steps": {
                "type": "array",
                "description": "Claves de los pasos que el usuario saltó a propósito («Ahora no»), para no volver a empujarlos. Opacas para el servidor y sin duplicados.",
                "maxItems": 50,
                "items": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 64
                },
                "examples": [
                  [
                    "brand",
                    "invite"
                  ]
                ]
              },
              "answers": {
                "type": "object",
                "description": "Respuestas del cuestionario del asistente (para qué va a usar Projekt, tamaño del equipo, si trabajan por sprints…), que ramifican el guion al reanudarlo. Claves y valores OPACOS para el servidor: el frontend define la taxonomía y codifica todo como texto (`\"yes\"`/`\"no\"` para las de sí/no). NO es sitio para datos personales ni de negocio: eso vive en su recurso (organización, cliente, empleado).",
                "maxProperties": 20,
                "propertyNames": {
                  "minLength": 1,
                  "maxLength": 64
                },
                "additionalProperties": {
                  "type": "string",
                  "maxLength": 200
                },
                "examples": [
                  {
                    "intent": "finance",
                    "team_size": "2-3"
                  }
                ]
              }
            }
          }
        }
      },
      "DashboardLayout": {
        "type": "object",
        "title": "DashboardLayout",
        "description": "Layout del dashboard del usuario autenticado: qué widgets muestra, en qué orden y con qué tamaño. Persistido a nivel de USUARIO (nunca de la organización) y es la FUENTE DE VERDAD cross-device; el frontend usa localStorage solo como caché optimista. Se lee con `GET /me/dashboard-layout` y se reemplaza por completo con `PUT /me/dashboard-layout` (mismo shape en ambos sentidos, sustitución total, no merge). Un usuario que nunca ha guardado nada recibe el estado vacío (`version=1`, `items=[]`), nunca 404: el frontend lo reconcilia contra su catálogo de widgets para producir el layout por defecto. Los `id` de widget y las claves de tamaño son OPACOS para el servidor.",
        "required": [
          "version",
          "items"
        ],
        "properties": {
          "version": {
            "type": "integer",
            "minimum": 1,
            "description": "Versión del esquema de layout con la que se guardó (el del frontend al guardar). El frontend migra el layout cuando su versión actual es MAYOR. Nunca 0: el estado vacío es `version=1, items=[]`.",
            "examples": [
              1
            ]
          },
          "items": {
            "type": "array",
            "description": "Widgets del dashboard EN ORDEN de render (el orden del array = el orden en el grid y el de tabulación por teclado). Sin ids duplicados.",
            "maxItems": 100,
            "items": {
              "$ref": "#/components/schemas/DashboardWidgetItem"
            }
          }
        }
      },
      "DashboardWidgetItem": {
        "type": "object",
        "title": "DashboardWidgetItem",
        "description": "Una instancia de widget en el layout del usuario. `id` es la clave del widget en el catálogo del frontend (opaca para el servidor).",
        "required": [
          "id",
          "size",
          "hidden"
        ],
        "properties": {
          "id": {
            "type": "string",
            "minLength": 1,
            "maxLength": 64,
            "description": "Clave del widget en el catálogo del frontend (p. ej. `finance-summary`, `crm-pipeline`, `weather`). OPACA para el servidor.",
            "examples": [
              "finance-summary"
            ]
          },
          "size": {
            "type": "string",
            "enum": [
              "sm",
              "md",
              "lg",
              "xl"
            ],
            "description": "Tamaño del widget en celdas del grid (`sm`=1 col … `xl`=ancho completo). El mapeo tamaño→columnas lo define el frontend.",
            "examples": [
              "lg"
            ]
          },
          "hidden": {
            "type": "boolean",
            "description": "`true` = el usuario lo quitó del grid (sigue en el catálogo, disponible para volver a añadirlo desde el cajón de widgets).",
            "examples": [
              false
            ]
          },
          "size_by_breakpoint": {
            "type": [
              "object",
              "null"
            ],
            "description": "Override opcional del tamaño por breakpoint (claves `sm`/`md`/`lg`/`xl` del frontend → tamaño). `null` u omitido = usa `size` en todos. Claves opacas para el servidor.",
            "maxProperties": 5,
            "propertyNames": {
              "minLength": 1,
              "maxLength": 8
            },
            "additionalProperties": {
              "type": "string",
              "enum": [
                "sm",
                "md",
                "lg",
                "xl"
              ]
            },
            "examples": [
              {
                "sm": "xl",
                "lg": "lg"
              }
            ]
          }
        }
      },
      "UiPreferencesUpdate": {
        "type": "object",
        "title": "UiPreferencesUpdate",
        "description": "Cambios en las preferencias de UI del usuario autenticado. Todos los campos son opcionales: se aplican solo los presentes (un cuerpo `{}` no cambia nada). Persistidas a nivel de USUARIO como fuente de verdad cross-device (antes solo en localStorage, que se perdía al cambiar de organización o cerrar sesión). Devuelve el `User` actualizado.",
        "properties": {
          "ambient_background": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 40,
            "description": "Clave del shader de fondo de ambiente Pro (p.ej. `chroma-waves`). `null` quita el fondo de ambiente. Clave opaca: la taxonomía la define el frontend.",
            "examples": [
              "chroma-waves"
            ]
          },
          "card_style": {
            "type": "string",
            "enum": [
              "solid",
              "translucent",
              "glass"
            ],
            "description": "Estilo visual de las cards del producto. `solid` es el valor por defecto de la cuenta; enviar otro valor lo cambia.",
            "examples": [
              "translucent"
            ]
          },
          "call_ringtone": {
            "type": "boolean",
            "description": "Si el aviso de llamada entrante suena además de verse. `false` es el valor por defecto de la cuenta. No admite `null` (la columna es NOT NULL): apagarlo es enviar `false`, y omitirlo lo deja intacto.",
            "examples": [
              true
            ]
          },
          "os_preferences": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true,
            "description": "Ajustes del escritorio (fondo de pantalla, widgets, orden del dock, navmenu compacta, geometría de ventanas por organización). Objeto OPACO: la taxonomía de claves la define el frontend, el backend la persiste tal cual. Se FUSIONA clave a clave con lo ya guardado —enviar una clave con `null` la borra—, nunca reemplaza el objeto entero: dos pestañas abiertas guardando ajustes distintos se pisarían la una a la otra. Topes: 100 claves, 80 caracteres por clave y 64 KB en total.",
            "examples": [
              {
                "projekt.os.fondo": "silk",
                "projekt.os.navmenu-compacta": true
              }
            ]
          }
        }
      },
      "Session": {
        "type": "object",
        "title": "Session",
        "description": "Sesión activa del usuario autenticado (un refresh token vivo = un dispositivo/navegador), en representación enmascarada. Nunca expone el token.",
        "required": [
          "id",
          "ip_address",
          "user_agent",
          "last_used_at",
          "created_at",
          "expires_at",
          "current"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID de la sesión (del refresh token; nunca el token en sí).",
            "examples": [
              "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b"
            ]
          },
          "ip_address": {
            "type": [
              "string",
              "null"
            ],
            "description": "IP (IPv4/IPv6) desde la que se abrió o rotó la sesión. `null` en sesiones anteriores a esta versión o si la petición no aportó IP resoluble.",
            "examples": [
              "203.0.113.7"
            ]
          },
          "user_agent": {
            "type": [
              "string",
              "null"
            ],
            "description": "User-Agent del navegador/dispositivo que abrió la sesión; `null` en sesiones antiguas o sin cabecera.",
            "examples": [
              "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
            ]
          },
          "last_used_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Última rotación del refresh (proxy de \"última actividad\"); `null` si la sesión aún no se ha refrescado desde que se abrió.",
            "examples": [
              "2026-07-19T10:30:00Z"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "Instante en que se abrió la sesión.",
            "examples": [
              "2026-07-19T09:00:00Z"
            ]
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "Caducidad del refresh token de la sesión.",
            "examples": [
              "2026-08-18T09:00:00Z"
            ]
          },
          "current": {
            "type": "boolean",
            "description": "`true` solo para la sesión con la que se hizo esta petición (la cookie de refresh actual). El frontend la etiqueta como «este dispositivo».",
            "examples": [
              true
            ]
          }
        }
      },
      "Organization": {
        "type": "object",
        "title": "Organization",
        "description": "Organización a la que pertenece el usuario autenticado.",
        "required": [
          "id",
          "name",
          "slug",
          "role",
          "member_count",
          "project_count",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          },
          "name": {
            "type": "string",
            "examples": [
              "3XA Inc"
            ]
          },
          "slug": {
            "type": "string",
            "description": "Identificador único URL-safe de la organización.",
            "examples": [
              "3xa"
            ]
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL del logotipo de la organización; `null` si no tiene.",
            "examples": [
              "https://cdn.projekt.3xa.es/logos/3xa.png"
            ]
          },
          "brand_color": {
            "type": [
              "string",
              "null"
            ],
            "description": "Color de marca en hexadecimal CSS (#RRGGBB); `null` si no tiene.",
            "examples": [
              "#FD2554"
            ]
          },
          "accent_color": {
            "type": [
              "string",
              "null"
            ],
            "description": "Color de acento en hexadecimal CSS (#RRGGBB); `null` si no tiene.",
            "examples": [
              "#1A1A1A"
            ]
          },
          "legal_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Razón social legal de la organización; `null` si no configurada.",
            "examples": [
              "3XA Inc S.L."
            ]
          },
          "fiscal_address": {
            "type": [
              "string",
              "null"
            ],
            "description": "Domicilio fiscal; `null` si no configurado.",
            "examples": [
              "Calle Mayor 1, 28001 Madrid, España"
            ]
          },
          "tax_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "NIF/CIF de la organización; `null` si no configurado.",
            "examples": [
              "B12345678"
            ]
          },
          "iban": {
            "type": [
              "string",
              "null"
            ],
            "description": "IBAN bancario (hasta 34 chars, sin espacios); `null` si no configurado.",
            "examples": [
              "ES9121000418450200051332"
            ]
          },
          "bic": {
            "type": [
              "string",
              "null"
            ],
            "description": "BIC/SWIFT del banco; `null` si no configurado.",
            "examples": [
              "CAIXESBBXXX"
            ]
          },
          "bank_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nombre del banco; `null` si no configurado.",
            "examples": [
              "CaixaBank"
            ]
          },
          "automations_enabled": {
            "type": "boolean",
            "description": "Interruptor global de automatizaciones de la organización. Si es `false`, el motor no aplica ninguna regla de automatización (PJKT-1848).",
            "examples": [
              true
            ]
          },
          "external_messaging_enabled": {
            "type": "boolean",
            "description": "¿Puede la gente de esta organización hablar por chat con gente de OTRAS organizaciones? Nace en `false` y hay que encenderlo a mano: una organización no empieza expuesta al directorio de nadie porque sí.\n\nHacen falta LAS DOS: que la mía esté abierta no sirve de nada si la suya está cerrada. Y se comprueba en cada acceso, no solo al abrir la conversación — apagarlo corta los hilos externos que ya existían en vez de dejarlos vivos para siempre. Ver `paths/chat-external.yaml`.",
            "examples": [
              false
            ]
          },
          "timezone": {
            "type": "string",
            "description": "Zona horaria IANA de la organización (por defecto `Europe/Madrid`). Es la zona en la que se interpreta su jornada laboral, y por tanto la que decide qué instantes cuentan como tiempo laborable (ver `/calendar/business-time/…`). La zona que manda es la de quien PRESTA el servicio, no la de quien mira la pantalla.",
            "examples": [
              "Europe/Madrid"
            ]
          },
          "week_start_day": {
            "type": "integer",
            "enum": [
              0,
              5,
              6
            ],
            "description": "Día en que empieza la SEMANA de la organización, en la convención `weekday()` de Python: `0` lunes (ISO-8601, por defecto), `5` sábado, `6` domingo. Lo respetan el planner, el calendario, los partes de horas y la capacidad: con `6`, la semana va de domingo a sábado y los periodos de parte de horas se anclan en domingo. En un alta nueva se deduce del `country` y se puede cambiar en cualquier momento; cambiarlo NO reubica los periodos de parte de horas ya creados con el anterior.",
            "examples": [
              6
            ]
          },
          "country": {
            "type": "string",
            "minLength": 2,
            "maxLength": 2,
            "description": "País de la organización en ISO 3166-1 alfa-2 (por defecto `ES`). Es el país de quien EMITE: decide qué obligaciones legales aplican a sus documentos (p. ej. la leyenda del RD 1619/2012 solo se imprime en facturas de `ES`) y qué etiquetas fiscales se usan. No cambia porque el cliente sea de otro país.",
            "examples": [
              "US"
            ]
          },
          "default_currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa BASE de la organización en ISO 4217 (por defecto `EUR`). Es la que se estampa en una factura o gasto nuevos cuando la petición no pide otra, y la unidad en la que están los agregados de `GET /finance/summary`. NO hay conversión de divisa en ningún punto del producto: importes en divisas distintas nunca se suman entre sí.",
            "examples": [
              "USD"
            ]
          },
          "locale": {
            "type": "string",
            "maxLength": 10,
            "description": "Locale BCP-47 de la organización (por defecto `es-ES`). Decide cómo se escriben números y fechas en los documentos que EMITE — `1.234,56 EUR` y `03/09/2026` en `es-ES`, `USD 1,234.56` y `09/03/2026` en `en-US`. El idioma con el que se le habla a una PERSONA es `User.language`, que gana sobre este.",
            "examples": [
              "en-US"
            ]
          },
          "role": {
            "type": "string",
            "description": "Rol del usuario autenticado en esta organización.",
            "enum": [
              "owner",
              "admin",
              "manager",
              "member"
            ]
          },
          "member_count": {
            "type": "integer",
            "description": "Número de miembros de la organización.",
            "examples": [
              8
            ]
          },
          "project_count": {
            "type": "integer",
            "description": "Número de proyectos asociados a la organización.",
            "examples": [
              3
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-08T12:00:00Z"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-08T12:00:00Z"
            ]
          },
          "last_activity": {
            "description": "Última actividad registrada en la organización (audit log más reciente). `null` si la organización no tiene actividad registrada aún.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/OrgLastActivity"
              },
              {
                "type": "null"
              }
            ]
          }
        }
      },
      "OrgLastActivity": {
        "type": "object",
        "title": "OrgLastActivity",
        "description": "Resumen de la última acción registrada en audit_logs para la organización.",
        "required": [
          "summary",
          "at"
        ],
        "properties": {
          "summary": {
            "type": "string",
            "description": "Etiqueta ES corta de la acción (e.g. \"Factura creada\", \"Tarea actualizada\").",
            "examples": [
              "Factura creada",
              "Tarea actualizada",
              "Sesión iniciada"
            ]
          },
          "at": {
            "type": "string",
            "format": "date-time",
            "description": "Momento en que ocurrió la última actividad (UTC).",
            "examples": [
              "2026-07-10T09:00:00Z"
            ]
          }
        }
      },
      "Member": {
        "type": "object",
        "title": "Member",
        "description": "Miembro de una organización, con su rol, fecha de incorporación y datos RRHH.",
        "required": [
          "user_id",
          "name",
          "email",
          "role",
          "joined_at",
          "is_active"
        ],
        "properties": {
          "user_id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID del usuario miembro.",
            "examples": [
              "6f1f9c3e-8f2a-4b6e-9a3d-2b1c0d4e5f60"
            ]
          },
          "name": {
            "type": "string",
            "examples": [
              "Nick Valdivia"
            ]
          },
          "email": {
            "type": "string",
            "format": "email",
            "examples": [
              "nick@3xa.es"
            ]
          },
          "avatar_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL del avatar del miembro; `null` si no tiene.",
            "examples": [
              "https://cdn.projekt.3xa.es/avatars/nick.png"
            ]
          },
          "role": {
            "type": "string",
            "description": "Rol del miembro en la organización.",
            "enum": [
              "owner",
              "admin",
              "manager",
              "member"
            ]
          },
          "joined_at": {
            "type": "string",
            "format": "date-time",
            "description": "Fecha de incorporación a la organización.",
            "examples": [
              "2026-07-08T12:00:00Z"
            ]
          },
          "employee_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID de la ficha employee vinculada; `null` si no hay ficha.",
            "examples": [
              "7f1c2d34-5678-4abc-9def-0123456789ab"
            ]
          },
          "department": {
            "type": [
              "string",
              "null"
            ],
            "description": "Departamento del empleado; `null` si no hay ficha o sin asignar.",
            "examples": [
              "Engineering"
            ]
          },
          "department_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del departamento estructurado vinculado a la ficha; `null` si no hay ficha o no está vinculada. Es el id que acepta el PATCH del miembro: sin declararlo aquí, quien lo escribía no podía leer lo que había escrito.",
            "examples": [
              "7f1c2d34-5678-4abc-9def-0123456789ab"
            ]
          },
          "position": {
            "type": [
              "string",
              "null"
            ],
            "description": "Cargo/puesto; `null` si no hay ficha o sin asignar.",
            "examples": [
              "Backend Engineer"
            ]
          },
          "phone": {
            "type": [
              "string",
              "null"
            ],
            "description": "Teléfono de contacto; `null` si no hay ficha o sin asignar.",
            "examples": [
              "+34 600 111 222"
            ]
          },
          "hire_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha de alta laboral; `null` si no hay ficha o sin asignar.",
            "examples": [
              "2024-01-15"
            ]
          },
          "employment_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "Tipo de relación laboral; `null` si no hay ficha o el rol no puede verlo. Se copia tal cual de la ficha employee, así que su conjunto es el mismo —ABIERTO— que el de `Employee.employment_type`: hoy interno/autonomo/externo (España) y w2/1099 (EE. UU.). Ver la nota de employee.yaml (PJKT-2266).",
            "x-known-values": [
              "interno",
              "autonomo",
              "externo",
              "w2",
              "1099"
            ]
          },
          "is_active": {
            "type": "boolean",
            "description": "Empleado activo. `true` por defecto cuando no hay ficha. Un manager+ puede desactivarlo vía PATCH con `is_active: false`.",
            "default": true
          },
          "manager_employee_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID de la ficha employee del manager directo; `null` si no hay.",
            "examples": [
              "a1b2c3d4-5678-4abc-9def-0123456789ef"
            ]
          },
          "manager_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nombre completo del manager directo; `null` si no hay.",
            "examples": [
              "Ana García"
            ]
          },
          "hourly_cost_rate": {
            "type": [
              "number",
              "null"
            ],
            "description": "Coste por hora del miembro (lo que le cuesta a la empresa); `null` si no hay ficha o no se ha fijado. Requiere manager+ para editarse.",
            "examples": [
              25
            ]
          },
          "hourly_bill_rate": {
            "type": [
              "number",
              "null"
            ],
            "description": "Tarifa por hora facturable al cliente; `null` si no hay ficha o no se ha fijado. Requiere manager+ para editarse.",
            "examples": [
              60
            ]
          },
          "expected_hours_per_week": {
            "type": [
              "number",
              "null"
            ],
            "description": "Override manual de horas esperadas por semana (capacidad, mig 0071); de la membership. `null` = derivar del horario del empleado o del departamento (modelo híbrido). Requiere manager+ para editarse.",
            "examples": [
              40
            ]
          }
        }
      },
      "MemberRoleUpdate": {
        "type": "object",
        "title": "MemberRoleUpdate",
        "description": "Actualización parcial de un miembro. `role` requiere owner/admin; los campos RRHH requieren manager+.",
        "properties": {
          "role": {
            "type": "string",
            "description": "Rol destino del miembro.",
            "enum": [
              "owner",
              "admin",
              "manager",
              "member"
            ]
          },
          "department": {
            "type": [
              "string",
              "null"
            ],
            "description": "Departamento; UPSERT de la ficha employee.",
            "examples": [
              "Engineering"
            ]
          },
          "department_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del departamento estructurado al que se vincula la ficha employee. `null` desvincula (el texto de `department` se CONSERVA). Si se envía sin `department`, el nombre del departamento se copia en él para que las dos formas no se contradigan. 422 si el departamento no es de esta organización.\nEl lote (`MemberBulkUpdateIn`) sí lo declaraba; el PATCH individual, no — así que el vínculo estructurado funcionaba en el API y ningún SDK podía usarlo por esta vía.",
            "examples": [
              "7f1c2d34-5678-4abc-9def-0123456789ab"
            ]
          },
          "position": {
            "type": [
              "string",
              "null"
            ],
            "description": "Cargo/puesto; UPSERT de la ficha employee.",
            "examples": [
              "Backend Engineer"
            ]
          },
          "phone": {
            "type": [
              "string",
              "null"
            ],
            "description": "Teléfono de contacto; UPSERT de la ficha employee.",
            "examples": [
              "+34 600 111 222"
            ]
          },
          "hire_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha de alta laboral; UPSERT de la ficha employee.",
            "examples": [
              "2024-01-15"
            ]
          },
          "employment_type": {
            "type": "string",
            "enum": [
              "interno",
              "autonomo",
              "externo",
              "w2",
              "1099"
            ],
            "description": "Tipo de relación laboral; UPSERT de la ficha employee. Enum CERRADO porque es escritura: ampliarlo con `w2`/`1099` es retrocompatible (lo que se aceptaba se sigue aceptando) y mantenerlo cerrado hace que un valor mal escrito reciba un 422 en vez de guardarse."
          },
          "is_active": {
            "type": "boolean",
            "description": "Desactivar (`false`) o reactivar (`true`) al miembro en la ficha employee. El miembro no se elimina de la organización al desactivarse."
          },
          "manager_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID de la ficha employee del manager directo. `null` elimina el manager. Devuelve 422 si el id no pertenece a la org, si es el propio empleado, o si crearía un ciclo en la jerarquía."
          },
          "hourly_cost_rate": {
            "type": [
              "number",
              "null"
            ],
            "description": "Coste por hora del miembro; UPSERT de la ficha employee. `null` lo borra. Requiere manager+.",
            "examples": [
              25
            ]
          },
          "hourly_bill_rate": {
            "type": [
              "number",
              "null"
            ],
            "description": "Tarifa por hora facturable al cliente; UPSERT de la ficha employee. `null` la borra. Requiere manager+.",
            "examples": [
              60
            ]
          },
          "expected_hours_per_week": {
            "type": [
              "number",
              "null"
            ],
            "description": "Override manual de horas esperadas por semana (capacidad, mig 0071); se escribe en la membership. `null` = usar el modelo híbrido (horario del empleado o del departamento). Requiere manager+.",
            "examples": [
              40
            ]
          }
        }
      },
      "InviteMemberIn": {
        "type": "object",
        "title": "InviteMemberIn",
        "required": [
          "identifier"
        ],
        "properties": {
          "identifier": {
            "type": "string",
            "description": "Email o nombre de usuario del invitado. Si contiene `@` se interpreta como email; de lo contrario se busca por nombre de usuario exacto.",
            "examples": [
              "maria@empresa.com",
              "maria.garcia"
            ]
          },
          "role": {
            "type": "string",
            "enum": [
              "member",
              "manager",
              "admin"
            ],
            "default": "member",
            "description": "Rol que recibirá el invitado. `owner` no es invitable directamente."
          }
        }
      },
      "OrgInvite": {
        "type": "object",
        "title": "OrgInvite",
        "required": [
          "id",
          "organization_id",
          "email",
          "role",
          "invited_by",
          "created_at",
          "expires_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID de la invitación."
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID de la organización a la que se invita."
          },
          "email": {
            "type": "string",
            "format": "email",
            "description": "Email del invitado."
          },
          "role": {
            "type": "string",
            "enum": [
              "owner",
              "admin",
              "manager",
              "member"
            ],
            "description": "Rol que recibirá el invitado al aceptar."
          },
          "invited_by": {
            "type": "string",
            "format": "uuid",
            "description": "UUID del usuario que envió la invitación."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time"
          },
          "accepted_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Fecha de aceptación; `null` si todavía está pendiente."
          }
        }
      },
      "OrgInvitePreview": {
        "type": "object",
        "title": "OrgInvitePreview",
        "description": "Lo que necesita la pantalla de invitación para explicarse sin sesión: a qué organización se invita, quién invita, con qué rol, a qué correo y hasta cuándo. No incluye ningún dato agregado de la organización.",
        "required": [
          "status",
          "organization_name",
          "organization_slug",
          "email",
          "role",
          "expires_at"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "expired",
              "accepted"
            ],
            "description": "`pending` se puede aceptar; `expired` caducó y hay que pedir otra; `accepted` ya se usó (la pantalla lleva al usuario a entrar)."
          },
          "organization_name": {
            "type": "string",
            "description": "Nombre de la organización que invita."
          },
          "organization_slug": {
            "type": "string",
            "description": "Slug de la organización (para la URL de destino)."
          },
          "organization_logo_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Logotipo de la organización, si tiene."
          },
          "organization_brand_color": {
            "type": [
              "string",
              "null"
            ],
            "description": "Color de marca de la organización (hex), para teñir la pantalla."
          },
          "email": {
            "type": "string",
            "format": "email",
            "description": "Correo al que se envió la invitación. Es el ÚNICO que puede aceptarla: la pertenencia se decide por él, no por quién abra el enlace."
          },
          "role": {
            "type": "string",
            "enum": [
              "owner",
              "admin",
              "manager",
              "member"
            ],
            "description": "Rol con el que entrará el invitado si acepta."
          },
          "invited_by_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nombre de quien envió la invitación (null si su cuenta ya no existe)."
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "Instante en que la invitación deja de poder aceptarse."
          }
        }
      },
      "InviteResult": {
        "type": "object",
        "title": "InviteResult",
        "description": "Resultado de POST .../members/invite. Exactamente uno de `member` o `invite` estará presente.",
        "properties": {
          "member": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Member"
              },
              {
                "type": "null"
              }
            ],
            "description": "Presente cuando el usuario ya existía y se añadió directamente como miembro."
          },
          "invite": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/OrgInvite"
              },
              {
                "type": "null"
              }
            ],
            "description": "Presente cuando el email no está registrado y se creó una invitación pendiente."
          }
        }
      },
      "BulkInviteIn": {
        "type": "object",
        "title": "BulkInviteIn",
        "description": "Alta masiva de miembros por correo. Los correos se normalizan (recorte y minúsculas) y los repetidos DENTRO de la lista se ignoran quedándose con la primera aparición. El rol se aplica a todos por igual; para roles distintos, envía una petición por rol.",
        "required": [
          "emails"
        ],
        "properties": {
          "emails": {
            "type": "array",
            "minItems": 1,
            "maxItems": 200,
            "description": "Correos a dar de alta. El tope de 200 por petición es el mismo que el de la actualización masiva de miembros; para plantillas mayores, trocea la lista.",
            "items": {
              "type": "string"
            },
            "examples": [
              [
                "maria@empresa.com",
                "JAVIER@Empresa.com"
              ]
            ]
          },
          "role": {
            "type": "string",
            "enum": [
              "member",
              "manager",
              "admin"
            ],
            "default": "member",
            "description": "Rol que recibirán todos los correos de la lista. `owner` no es asignable por invitación (se transfiere con PATCH .../members/{user_id})."
          }
        }
      },
      "BulkInviteItem": {
        "type": "object",
        "title": "BulkInviteItem",
        "description": "Desenlace de un correo concreto del lote. Con 40 correos, el valor está en saber cuál de ellos falló y por qué, no en un éxito o fracaso global.",
        "required": [
          "email",
          "status"
        ],
        "properties": {
          "email": {
            "type": "string",
            "description": "Correo normalizado (minúsculas, sin espacios). Para `invalid_email` se devuelve lo que se envió (recortado) para que el usuario lo reconozca en su lista.",
            "examples": [
              "maria@empresa.com"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "invited",
              "added",
              "already_member",
              "already_invited",
              "duplicate",
              "invalid_email",
              "plan_limit_reached"
            ],
            "description": "* `invited`: correo sin cuenta → invitación pendiente creada y correo\n  encolado.\n* `added`: ya tenía cuenta en Projekt y no era miembro → alta directa,\n  sin esperar a que acepte nada.\n* `already_member`: ya pertenecía a la organización. * `already_invited`: ya tenía una invitación pendiente vigente; no se\n  crea otra ni se reenvía el correo.\n* `duplicate`: repetido dentro de la propia lista enviada. * `invalid_email`: no tiene forma de dirección de correo. * `plan_limit_reached`: no quedaban plazas de miembro en el plan. El\n  presupuesto de plazas se calcula ANTES de dar de alta a nadie, así que\n  el lote nunca se queda a medias por sorpresa."
          },
          "invite_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Invitación asociada: la creada (`invited`) o la que ya existía (`already_invited`). `null` en el resto de estados."
          },
          "user_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Usuario asociado en `added` y `already_member`. `null` en el resto."
          }
        }
      },
      "BulkInviteResult": {
        "type": "object",
        "title": "BulkInviteResult",
        "description": "Resultado del alta masiva. La respuesta es 200 aunque haya correos fallidos: un lote es parcial por naturaleza y el desenlace vive en cada fila de `results`, no en el código de estado.",
        "required": [
          "results",
          "invited",
          "added",
          "skipped",
          "failed"
        ],
        "properties": {
          "results": {
            "type": "array",
            "description": "Una fila por correo enviado, en el mismo orden de la petición.",
            "items": {
              "$ref": "#/components/schemas/BulkInviteItem"
            }
          },
          "invited": {
            "type": "integer",
            "minimum": 0,
            "description": "Invitaciones pendientes creadas (`invited`)."
          },
          "added": {
            "type": "integer",
            "minimum": 0,
            "description": "Usuarios ya registrados añadidos directamente (`added`)."
          },
          "skipped": {
            "type": "integer",
            "minimum": 0,
            "description": "Correos que no requerían acción: `already_member`, `already_invited` o `duplicate`."
          },
          "failed": {
            "type": "integer",
            "minimum": 0,
            "description": "Correos rechazados: `invalid_email` o `plan_limit_reached`."
          }
        }
      },
      "Project": {
        "type": "object",
        "title": "Project",
        "description": "Proyecto perteneciente a una organización.",
        "required": [
          "id",
          "organization_id",
          "key",
          "name",
          "description",
          "status",
          "visibility",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "9c8b7a65-4321-4fed-cba9-876543210fed"
            ]
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          },
          "key": {
            "type": "string",
            "description": "Clave JIRA-style del proyecto, única por organización (e.g. `PJKT`). Se genera automáticamente desde el nombre al crear el proyecto.",
            "examples": [
              "PJKT"
            ]
          },
          "name": {
            "type": "string",
            "examples": [
              "Projekt"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "description": "Descripción libre; `null` si no se ha definido.",
            "examples": [
              "Monorepo Next.js + FastAPI"
            ]
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL del logotipo del proyecto; `null` si no tiene.",
            "examples": [
              "https://cdn.projekt.3xa.es/logos/projekt.png"
            ]
          },
          "department_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Departamento (de la misma organización) al que pertenece el proyecto. Base del permiso derivado: los miembros del departamento acceden a sus proyectos. `null` si el proyecto no está vinculado a ningún departamento.",
            "examples": [
              "7f3d9b21-0c4e-4a5b-9d8e-1f2a3b4c5d6e"
            ]
          },
          "client_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Cliente (de la misma organización) al que pertenece el proyecto — la columna vertebral cliente↔proyecto: habilita el roll-up de rentabilidad por cliente y la ficha 360º. `null` si el proyecto no está vinculado a ningún cliente.",
            "examples": [
              "3f1a2b4c-5d6e-4f7a-8b9c-0d1e2f3a4b5c"
            ]
          },
          "source_quote_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Presupuesto aceptado del que nació el proyecto (`null` si se creó a mano). Es la trazabilidad de la venta: de qué se cobró viene el trabajo que se está ejecutando. Lo fija el servidor al convertir el presupuesto; no se puede mandar al crear un proyecto.",
            "examples": [
              "7a1b2c3d-4e5f-4061-8273-8495a6b7c8d9"
            ]
          },
          "folder_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Carpeta (de la misma organización) en la que está agrupado el proyecto (plan Business en adelante). `null` = suelto, que es el estado normal y el de todos los proyectos anteriores a la función. La carpeta NO decide quién ve el proyecto: eso lo resuelven `visibility`, los miembros explícitos y los departamentos.",
            "examples": [
              "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d"
            ]
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Proyecto del que cuelga éste (un SUBPROYECTO: una app y sus subsistemas, 08/09/2026). `null` = proyecto raíz, que es lo que son todos los anteriores. PROFUNDIDAD LIBRE desde el 09/09/2026: un subproyecto puede tener subproyectos (una app, sus subsistemas y los de éstos). Lo único que se rechaza es un CICLO —hacer a un proyecto descendiente de sí mismo—, con `subproject_cycle`; no hay tope de niveles. Un subproyecto es un proyecto completo —clave propia, tareas, tablero, miembros— y al nacer hereda del padre la visibilidad, los departamentos y los miembros explícitos, y el cliente y el departamento si no se indican."
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "archived"
            ]
          },
          "visibility": {
            "type": "string",
            "enum": [
              "organization",
              "restricted"
            ],
            "description": "Visibilidad del proyecto dentro de su organización. `organization` (por defecto, y valor de TODOS los proyectos preexistentes): visible a cualquier miembro de la org. `restricted`: visible solo a owner/admin, a los miembros explícitos (`project_members`) o por el permiso derivado de departamento.",
            "examples": [
              "organization"
            ]
          },
          "visibility_departments": {
            "type": [
              "array",
              "null"
            ],
            "description": "Departamentos (M:N `project_visibility_departments`) cuyos miembros pueden ver este proyecto cuando es `restricted` — uno o VARIOS a la vez; el caller accede si pertenece a CUALQUIERA de ellos. Se calcula solo en el listado (`listProjects`); `null` en los endpoints que no lo resuelven (para el detalle/gestión úsese `listProjectVisibilityDepartments`). Lista vacía = sin departamentos vinculados (solo miembros explícitos / owner-admin).",
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "examples": [
              [
                "7f3d9b21-0c4e-4a5b-9d8e-1f2a3b4c5d6e"
              ]
            ]
          },
          "is_member": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "¿El usuario autenticado es miembro EXPLÍCITO de este proyecto? Se calcula solo en el listado (`listProjects`); `null` en los endpoints que no lo resuelven.",
            "examples": [
              false
            ]
          },
          "board_swimlane": {
            "type": [
              "string",
              "null"
            ],
            "description": "Agrupación de swimlanes del tablero Kanban; `null` = sin swimlanes.",
            "enum": [
              "none",
              "assignee",
              "priority",
              "type"
            ],
            "examples": [
              "assignee"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-08T12:00:00Z"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "Última modificación de la fila del proyecto (nombre, estado, logo…).",
            "examples": [
              "2026-07-10T09:30:00Z"
            ]
          },
          "last_activity_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Última actualización realizada sobre el proyecto: máximo entre `updated_at` del proyecto y el `updated_at` más reciente de sus tareas. `null` cuando el endpoint no la calcula.",
            "examples": [
              "2026-07-12T18:45:00Z"
            ]
          }
        }
      },
      "ProjectActivity": {
        "type": "object",
        "title": "ProjectActivity",
        "description": "Recent activity of one project, as one bucket per day. A bucket counts the tasks whose `updated_at` falls on that day (UTC), which is the same thing the project card has always drawn.",
        "required": [
          "project_id",
          "buckets"
        ],
        "properties": {
          "project_id": {
            "type": "string",
            "format": "uuid"
          },
          "buckets": {
            "type": "array",
            "description": "One integer per day, oldest first and TODAY last. Its length is always the `days` asked for, so a project with no activity comes back as a run of zeros rather than an empty array — the sparkline needs the shape, not the absence.",
            "items": {
              "type": "integer",
              "minimum": 0
            },
            "examples": [
              [
                0,
                2,
                0,
                1,
                4,
                0,
                3
              ]
            ]
          }
        }
      },
      "ProjectMember": {
        "type": "object",
        "title": "ProjectMember",
        "description": "Un usuario de la organización con acceso explícito a un proyecto restringido (`visibility='restricted'`). Solo lo gestionan admin/manager.",
        "required": [
          "id",
          "project_id",
          "user_id",
          "user_name",
          "user_email",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "project_id": {
            "type": "string",
            "format": "uuid"
          },
          "user_id": {
            "type": "string",
            "format": "uuid"
          },
          "user_name": {
            "type": "string",
            "description": "Nombre del usuario miembro.",
            "examples": [
              "Ada Lovelace"
            ]
          },
          "user_email": {
            "type": "string",
            "description": "Email del usuario miembro.",
            "examples": [
              "ada@example.com"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "Momento en que se añadió al usuario como miembro del proyecto."
          }
        }
      },
      "ProjectAnnouncement": {
        "type": "object",
        "title": "ProjectAnnouncement",
        "required": [
          "id",
          "project_id",
          "organization_id",
          "author_id",
          "author_name",
          "title",
          "body",
          "link",
          "pinned",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "project_id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "author_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Autor del aviso; `null` si la cuenta se dio de baja (el aviso sobrevive a la persona)."
          },
          "author_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nombre del autor, resuelto en el listado; `null` si ya no existe.",
            "examples": [
              "Nicolás Valfiguer"
            ]
          },
          "title": {
            "type": "string",
            "maxLength": 200,
            "examples": [
              "Congelamos el alcance de la release"
            ]
          },
          "body": {
            "type": [
              "string",
              "null"
            ],
            "description": "Cuerpo del aviso (texto); `null` si es solo titular."
          },
          "link": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 512,
            "description": "Enlace opcional al que apunta el aviso. Si viene, es el deep-link que abre la notificación push."
          },
          "pinned": {
            "type": "boolean",
            "description": "Fijado arriba del tablón.",
            "examples": [
              false
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ProjectFollowState": {
        "type": "object",
        "title": "ProjectFollowState",
        "required": [
          "following",
          "muted",
          "source"
        ],
        "properties": {
          "following": {
            "type": "boolean",
            "description": "¿Recibe el usuario los avisos de este proyecto? Equivale a \"existe vínculo y NO está silenciado\".",
            "examples": [
              true
            ]
          },
          "muted": {
            "type": "boolean",
            "description": "¿El usuario silenció este proyecto explícitamente? Un proyecto silenciado no vuelve a activarse por auto-follow.",
            "examples": [
              false
            ]
          },
          "source": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "auto",
              "manual",
              null
            ],
            "description": "Cómo nació el vínculo: `auto` (auto-follow por actividad) o `manual` (el usuario pulsó Seguir). `null` si no existe vínculo todavía.",
            "examples": [
              "auto"
            ]
          }
        }
      },
      "ProjectFollower": {
        "type": "object",
        "title": "ProjectFollower",
        "required": [
          "user_id",
          "user_name",
          "muted",
          "source",
          "created_at"
        ],
        "properties": {
          "user_id": {
            "type": "string",
            "format": "uuid"
          },
          "user_name": {
            "type": "string",
            "examples": [
              "Nicolás Valfiguer"
            ]
          },
          "muted": {
            "type": "boolean",
            "description": "Silenciado por el propio usuario; no recibe avisos del proyecto.",
            "examples": [
              false
            ]
          },
          "source": {
            "type": "string",
            "enum": [
              "auto",
              "manual"
            ],
            "description": "`auto` (auto-follow por actividad) o `manual` (pulsó Seguir).",
            "examples": [
              "auto"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ProjectAccessLevel": {
        "type": "string",
        "title": "ProjectAccessLevel",
        "enum": [
          "read",
          "write"
        ],
        "description": "Nivel de acceso de una asociación proyecto↔departamento: `read` (solo lectura) o `write` (lectura y escritura). Si un usuario pertenece a VARIOS departamentos asociados al mismo proyecto, gana el nivel MÁS ALTO (`write` > `read`).",
        "examples": [
          "write"
        ]
      },
      "ProjectVisibilityDepartment": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Department"
          },
          {
            "type": "object",
            "title": "ProjectVisibilityDepartmentAccess",
            "required": [
              "access_level"
            ],
            "properties": {
              "access_level": {
                "$ref": "#/components/schemas/ProjectAccessLevel"
              }
            }
          }
        ]
      },
      "DepartmentVisibilityProject": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Project"
          },
          {
            "type": "object",
            "title": "DepartmentVisibilityProjectAccess",
            "required": [
              "access_level"
            ],
            "properties": {
              "access_level": {
                "$ref": "#/components/schemas/ProjectAccessLevel"
              }
            }
          }
        ]
      },
      "Task": {
        "type": "object",
        "title": "Task",
        "description": "Tarea perteneciente a un proyecto de una organización.",
        "required": [
          "id",
          "project_id",
          "project_name",
          "organization_id",
          "number",
          "reference",
          "title",
          "description",
          "status",
          "priority",
          "type",
          "assignee_id",
          "assignee_name",
          "created_by",
          "created_by_name",
          "sprint_id",
          "story_points",
          "estimated_hours",
          "start_date",
          "due_date",
          "due_at",
          "completed_date",
          "tags",
          "parent_id",
          "subtask_count",
          "subtasks_done",
          "attachment_count",
          "custom_fields",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "5d4c3b2a-1098-4765-bade-f01234567890"
            ]
          },
          "project_id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "9c8b7a65-4321-4fed-cba9-876543210fed"
            ]
          },
          "project_name": {
            "type": "string",
            "description": "Nombre del proyecto al que pertenece la tarea. Lo resuelve el servidor con el proyecto que ya carga para componer `reference` (mismo objeto, cero queries extra), y ahorra al cliente un `GET .../projects/{id}` con el que solo quería rotular la ficha.",
            "examples": [
              "Rediseño de la web"
            ]
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          },
          "number": {
            "type": "integer",
            "description": "Número secuencial de la tarea dentro del proyecto; empieza en 1.",
            "examples": [
              123
            ]
          },
          "reference": {
            "type": "string",
            "description": "Referencia legible `{KEY}-{number}` (e.g. `PJKT-123`), única por organización.",
            "examples": [
              "PJKT-123"
            ]
          },
          "title": {
            "type": "string",
            "examples": [
              "Cablear PATCH de proyecto"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "description": "Descripción libre; `null` si no se ha definido.",
            "examples": [
              "Añadir el endpoint al contrato y regenerar el SDK"
            ]
          },
          "status": {
            "type": "string",
            "description": "Estado de la tarea. Además de los 4 base (todo/in_progress/done/cancelled) puede ser un estado PERSONALIZADO de columna de tablero (slug `^[a-z0-9_]+$`)."
          },
          "priority": {
            "type": "string",
            "enum": [
              "low",
              "medium",
              "high",
              "urgent"
            ]
          },
          "type": {
            "type": "string",
            "description": "Tipo de trabajo (PM); por defecto `task`.",
            "enum": [
              "epic",
              "story",
              "task",
              "bug",
              "spike",
              "chore"
            ]
          },
          "assignee_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "UUID del miembro asignado; `null` si la tarea no está asignada.",
            "examples": [
              "6f1f9c3e-8f2a-4b6e-9a3d-2b1c0d4e5f60"
            ]
          },
          "assignee_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nombre del miembro asignado; `null` si la tarea no está asignada.",
            "examples": [
              "Nick Valdivia"
            ]
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del usuario que creó la tarea; `null` para tareas migradas sin autoría registrada.",
            "examples": [
              "7a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
            ]
          },
          "created_by_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nombre del usuario que creó la tarea; `null` para tareas migradas o si el usuario se eliminó.",
            "examples": [
              "Nick Valdivia"
            ]
          },
          "sprint_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Sprint al que pertenece la tarea; `null` si no está en ningún sprint. El sprint debe ser del mismo proyecto que la tarea.",
            "examples": [
              "3a2b1c0d-4e5f-4a6b-8c9d-0e1f2a3b4c5d"
            ]
          },
          "story_points": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Estimación en puntos de historia; `null` si no se ha estimado.",
            "examples": [
              8
            ]
          },
          "estimated_hours": {
            "type": [
              "string",
              "null"
            ],
            "description": "Estimación en horas (`DECIMAL(6,2)` serializado como string); `null` si no se ha estimado.",
            "examples": [
              "12.50"
            ]
          },
          "start_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha de inicio planificada (ISO `YYYY-MM-DD`); `null` si no tiene.",
            "examples": [
              "2026-08-01"
            ]
          },
          "due_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha de vencimiento (ISO `YYYY-MM-DD`); `null` si no tiene fecha. Es el compromiso de DÍA y sigue siendo el campo por el que se filtra y se ordena. Cuando hay `due_at`, esta es su fecha local en la zona de la organización: nunca se contradicen.",
            "examples": [
              "2026-08-15"
            ]
          },
          "due_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Vencimiento CON HORA (instante, UTC); `null` si el compromiso es solo de día. Es el que necesitan los compromisos de servicio, que se miden en instantes y no en días. Fijarlo actualiza también `due_date` con su fecha local, así que los listados y filtros por fecha siguen valiendo.",
            "examples": [
              "2026-08-15T16:00:00Z"
            ]
          },
          "completed_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha en que la tarea DEJÓ DE ESTAR VIVA (ISO `YYYY-MM-DD`); `null` mientras sigue abierta. Se asigna automáticamente a la fecha actual cuando la tarea pasa a `done` o a `cancelled` y está vacía; puede fijarse o limpiarse a mano y nunca se sobrescribe una fecha ya existente.\n`cancelled` entra el 31/08/2026: el tablero retira lo cerrado hace más de un mes y necesita saber CUÁNDO se cerró. Una cancelada no tenía fecha nunca, así que su columna crecía para siempre. El nombre del campo se queda —es contrato `/api/v1` y renombrarlo sería breaking—, pero lo que significa es «cuándo se cerró», no «cuándo se completó».",
            "examples": [
              "2026-08-10"
            ]
          },
          "tags": {
            "type": "array",
            "description": "Etiquetas de la tarea (vacío si no tiene). Se fija con `setTaskTags`.",
            "items": {
              "$ref": "#/components/schemas/TagRef"
            }
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID de la tarea padre; `null` si no es subtarea."
          },
          "subtask_count": {
            "type": "integer",
            "description": "Número de subtareas directas de esta tarea.",
            "examples": [
              3
            ]
          },
          "subtasks_done": {
            "type": "integer",
            "description": "Número de subtareas directas con status `done`.",
            "examples": [
              1
            ]
          },
          "attachment_count": {
            "type": "integer",
            "description": "Número de adjuntos de la tarea.",
            "examples": [
              0
            ]
          },
          "custom_fields": {
            "type": "array",
            "description": "Valores de campos personalizados asignados a esta tarea (vacío si no tiene).",
            "items": {
              "$ref": "#/components/schemas/CustomFieldValue"
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-08T12:00:00Z"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-08T12:30:00Z"
            ]
          }
        }
      },
      "TaskActivityEntry": {
        "type": "object",
        "title": "TaskActivityEntry",
        "description": "Entrada del feed de actividad (bitácora) de una tarea.",
        "required": [
          "id",
          "action",
          "actor_id",
          "actor_name",
          "created_at",
          "payload"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID de la entrada de auditoría.",
            "examples": [
              "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
            ]
          },
          "action": {
            "type": "string",
            "description": "Acción auditada. Valores posibles: `task.created`, `task.status_changed`, `task.deleted`, `task.tags_set`, `task_comment.created`, `task_comment.updated`, `task_comment.deleted`.",
            "examples": [
              "task.status_changed"
            ]
          },
          "actor_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del usuario que realizó la acción; `null` si el usuario se eliminó.",
            "examples": [
              "7a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
            ]
          },
          "actor_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nombre del usuario que realizó la acción; `null` si el usuario se eliminó.",
            "examples": [
              "Nick Valdivia"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "Marca temporal UTC de la acción.",
            "examples": [
              "2026-07-13T10:30:00Z"
            ]
          },
          "payload": {
            "type": "object",
            "description": "Contexto adicional de la acción (e.g. `{\"task_id\":\"…\",\"from\":\"todo\",\"to\":\"in_progress\"}`). La estructura varía por acción; siempre incluye `task_id`.",
            "additionalProperties": true
          }
        }
      },
      "StatusTime": {
        "type": "object",
        "title": "StatusTime",
        "description": "Tiempo acumulado por estado de una tarea. `entries` incluye estados personalizados; el estado actual cuenta hasta ahora, salvo si es terminal. `total_seconds` es la suma de todos los tramos.",
        "required": [
          "task_id",
          "entries",
          "total_seconds",
          "accountable_seconds"
        ],
        "properties": {
          "task_id": {
            "type": "string",
            "format": "uuid"
          },
          "entries": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/StatusTimeEntry"
            }
          },
          "total_seconds": {
            "type": "integer",
            "description": "Suma de segundos de todos los estados. El reloj SE PARA al entrar en un estado terminal (`done`, `cancelled`): esos tramos aportan 0 segundos, así que el total de una tarea cerrada no crece con el tiempo. Si la tarea se reabre, el reloj se reanuda y el paréntesis en que estuvo cerrada no cuenta."
          },
          "accountable_seconds": {
            "type": "integer",
            "description": "Subconjunto de `total_seconds` que es tiempo imputable: excluye los tramos en columnas `in_review` y `blocked` (la tarea estaba entregada o parada, esperando a otro) y los terminales. Es el número que debe usar cualquier medición de duración o de SLA."
          }
        }
      },
      "StatusTimeEntry": {
        "type": "object",
        "title": "StatusTimeEntry",
        "description": "Tiempo acumulado (segundos) que una tarea ha estado en un estado.",
        "required": [
          "status",
          "seconds",
          "category",
          "accountable"
        ],
        "properties": {
          "status": {
            "type": "string",
            "description": "Estado (los 4 base o un estado PERSONALIZADO de columna, slug `^[a-z0-9_]+$`)."
          },
          "seconds": {
            "type": "integer",
            "description": "Segundos acumulados en ese estado. Los estados terminales (`done`, `cancelled`) aparecen con 0: el reloj se para al entrar en ellos. Se listan igualmente para dejar constancia de que la tarea pasó por ahí."
          },
          "category": {
            "type": "string",
            "enum": [
              "todo",
              "in_progress",
              "in_review",
              "blocked",
              "done",
              "cancelled"
            ],
            "description": "Categoría de la columna del tablero que corresponde a ese estado. Si la columna ya no existe, se deduce del slug conocido (`in_progress` por defecto)."
          },
          "accountable": {
            "type": "boolean",
            "description": "Si esos segundos son tiempo imputable. Falso en `in_review` y `blocked` (la tarea estaba esperando a otro) y en las terminales."
          }
        }
      },
      "Sprint": {
        "type": "object",
        "title": "Sprint",
        "description": "Iteración (sprint) perteneciente a un proyecto de una organización.",
        "required": [
          "id",
          "project_id",
          "organization_id",
          "name",
          "goal",
          "status",
          "start_date",
          "end_date",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "3a2b1c0d-4e5f-4a6b-8c9d-0e1f2a3b4c5d"
            ]
          },
          "project_id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "maxLength": 160,
            "examples": [
              "Sprint 12 — Facturación"
            ]
          },
          "goal": {
            "type": [
              "string",
              "null"
            ],
            "description": "Objetivo del sprint; `null` si no se ha definido.",
            "examples": [
              "Cerrar el flujo de aprobación de facturas"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "planning",
              "active",
              "completed"
            ],
            "description": "Estado del sprint; por defecto `planning`."
          },
          "start_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha de inicio (ISO `YYYY-MM-DD`); `null` si no tiene.",
            "examples": [
              "2026-08-01"
            ]
          },
          "end_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha de fin (ISO `YYYY-MM-DD`); `null` si no tiene.",
            "examples": [
              "2026-08-14"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "SprintBurndown": {
        "type": "object",
        "title": "SprintBurndown",
        "description": "Serie temporal ideal-vs-real de puntos restantes de un sprint.",
        "required": [
          "start_date",
          "end_date",
          "total_points",
          "days"
        ],
        "properties": {
          "start_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Inicio del sprint; `null` si no tiene (entonces `days` va vacío)."
          },
          "end_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fin del sprint; `null` si no tiene (entonces `days` va vacío)."
          },
          "total_points": {
            "type": "integer",
            "description": "Suma de story_points de las tareas del sprint (null = 0).",
            "examples": [
              34
            ]
          },
          "days": {
            "type": "array",
            "description": "Un punto por día de calendario del rango `start_date..end_date` (inclusive); vacío si faltan fechas.",
            "items": {
              "type": "object",
              "required": [
                "date",
                "ideal_remaining",
                "actual_remaining"
              ],
              "properties": {
                "date": {
                  "type": "string",
                  "format": "date"
                },
                "ideal_remaining": {
                  "type": "number",
                  "description": "Puntos restantes según la línea ideal (lineal total→0).",
                  "examples": [
                    25.5
                  ]
                },
                "actual_remaining": {
                  "type": [
                    "number",
                    "null"
                  ],
                  "description": "Puntos restantes reales ese día; `null` para días futuros (más allá de hoy).",
                  "examples": [
                    28
                  ]
                }
              }
            }
          }
        }
      },
      "SprintStats": {
        "type": "object",
        "title": "SprintStats",
        "description": "Agregados (tareas y puntos) de un sprint.",
        "required": [
          "total_tasks",
          "done_tasks",
          "total_points",
          "done_points",
          "by_status"
        ],
        "properties": {
          "total_tasks": {
            "type": "integer",
            "examples": [
              12
            ]
          },
          "done_tasks": {
            "type": "integer",
            "examples": [
              5
            ]
          },
          "total_points": {
            "type": "integer",
            "description": "Suma de story_points de las tareas del sprint (null = 0).",
            "examples": [
              34
            ]
          },
          "done_points": {
            "type": "integer",
            "description": "Suma de story_points de las tareas `done` (null = 0).",
            "examples": [
              13
            ]
          },
          "by_status": {
            "type": "array",
            "description": "Recuento de tareas por estado, con UNA entrada por cada uno de los cuatro estados base (`todo`, `in_progress`, `done`, `cancelled`) aunque su recuento sea 0, y en ese orden. Las tareas en estados PERSONALIZADOS de columnas de tablero no aparecen aquí: para el desglose completo está el tablero. Suma: `total_tasks` puede ser mayor que la suma de estas cuatro.",
            "items": {
              "type": "object",
              "required": [
                "status",
                "count"
              ],
              "properties": {
                "status": {
                  "type": "string",
                  "enum": [
                    "todo",
                    "in_progress",
                    "done",
                    "cancelled"
                  ]
                },
                "count": {
                  "type": "integer"
                }
              }
            }
          }
        }
      },
      "SprintComplete": {
        "type": "object",
        "title": "SprintComplete",
        "description": "Sprint cerrado (`status=completed`), sus stats finales (congeladas) y el destino del rollover de las tareas que quedaron incompletas.",
        "required": [
          "sprint",
          "stats",
          "rolled_over_task_count",
          "rollover_target_sprint_id"
        ],
        "properties": {
          "sprint": {
            "$ref": "#/components/schemas/Sprint"
          },
          "stats": {
            "$ref": "#/components/schemas/SprintStats"
          },
          "rolled_over_task_count": {
            "type": "integer",
            "description": "Nº de tareas incompletas (`todo`/`in_progress`) movidas por el rollover.",
            "examples": [
              3
            ]
          },
          "rollover_target_sprint_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Sprint destino del rollover; `null` si fueron al backlog (sin sprint, porque no había otro sprint `active` en el proyecto)."
          }
        }
      },
      "SprintVelocity": {
        "type": "object",
        "title": "SprintVelocity",
        "description": "Story points y tareas `done` por cada sprint cerrado del proyecto, más el promedio simple (media móvil si se pidió `limit`).",
        "required": [
          "sprints",
          "average_points",
          "average_tasks"
        ],
        "properties": {
          "sprints": {
            "type": "array",
            "description": "Sprints `completed`, en orden cronológico ascendente.",
            "items": {
              "type": "object",
              "title": "SprintVelocityEntry",
              "required": [
                "sprint_id",
                "name",
                "start_date",
                "end_date",
                "completed_tasks",
                "completed_points"
              ],
              "properties": {
                "sprint_id": {
                  "type": "string",
                  "format": "uuid"
                },
                "name": {
                  "type": "string"
                },
                "start_date": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "format": "date"
                },
                "end_date": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "format": "date"
                },
                "completed_tasks": {
                  "type": "integer",
                  "description": "Tareas `done` del sprint.",
                  "examples": [
                    5
                  ]
                },
                "completed_points": {
                  "type": "integer",
                  "description": "Suma de story_points de esas tareas (null cuenta como 0).",
                  "examples": [
                    21
                  ]
                }
              }
            }
          },
          "average_points": {
            "type": "number",
            "description": "Media simple de `completed_points` sobre los sprints devueltos.",
            "examples": [
              18.5
            ]
          },
          "average_tasks": {
            "type": "number",
            "description": "Media simple de `completed_tasks` sobre los sprints devueltos.",
            "examples": [
              4.5
            ]
          }
        }
      },
      "TagRef": {
        "type": "object",
        "title": "TagRef",
        "description": "Referencia compacta a una etiqueta embebida en una tarea.",
        "required": [
          "id",
          "name",
          "color"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "7a1b2c3d-4e5f-4061-8273-849506172839"
            ]
          },
          "name": {
            "type": "string",
            "examples": [
              "Urgente"
            ]
          },
          "color": {
            "type": "string",
            "examples": [
              "#fd2554"
            ]
          }
        }
      },
      "Tag": {
        "type": "object",
        "title": "Tag",
        "description": "Etiqueta (label) de una organización; el nombre es único por org.",
        "required": [
          "id",
          "organization_id",
          "name",
          "color",
          "created_at",
          "used_count"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "7a1b2c3d-4e5f-4061-8273-849506172839"
            ]
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          },
          "name": {
            "type": "string",
            "maxLength": 50,
            "description": "Nombre de la etiqueta; único (case-insensitive) dentro de la org.",
            "examples": [
              "Urgente"
            ]
          },
          "color": {
            "type": "string",
            "description": "Color hex (`#rgb`/`#rrggbb`) o nombre de token (p. ej. `slate`).",
            "examples": [
              "#fd2554"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-09T12:00:00Z"
            ]
          },
          "used_count": {
            "type": "integer",
            "description": "Nº de tareas de la organización que tienen este tag asignado. Se calcula al vuelo (COUNT sobre `task_tags`), no se almacena; 0 cuando no se usa en ninguna tarea.",
            "examples": [
              3
            ]
          }
        }
      },
      "TaskComment": {
        "type": "object",
        "title": "TaskComment",
        "description": "Comentario perteneciente a una tarea (hilos por `parent_id`).",
        "required": [
          "id",
          "task_id",
          "author_id",
          "author_name",
          "body",
          "parent_id",
          "is_edited",
          "visibility",
          "channel",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "1a2b3c4d-5e6f-4071-8283-949506172839"
            ]
          },
          "task_id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "5d4c3b2a-1098-4765-bade-f01234567890"
            ]
          },
          "author_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del autor; `null` si el autor fue borrado.",
            "examples": [
              "6f1f9c3e-8f2a-4b6e-9a3d-2b1c0d4e5f60"
            ]
          },
          "author_name": {
            "type": "string",
            "description": "Nombre del autor; `\"Sistema\"` si el autor fue borrado.",
            "examples": [
              "Nick Valdivia"
            ]
          },
          "body": {
            "type": "string",
            "description": "Cuerpo del comentario (texto libre).",
            "examples": [
              "Revisado; falta cablear el endpoint."
            ]
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Comentario padre si es una respuesta; `null` si es de primer nivel.",
            "examples": [
              "1a2b3c4d-5e6f-4071-8283-949506172839"
            ]
          },
          "is_edited": {
            "type": "boolean",
            "description": "`true` si el comentario fue editado tras su creación."
          },
          "visibility": {
            "type": "string",
            "enum": [
              "internal",
              "public"
            ],
            "description": "Quién lee este comentario. `internal` = solo el equipo. `public` = el cliente lo ve en el portal de su petición. La visibilidad NO se puede cambiar después de crear el comentario: publicar a posteriori es exactamente la forma de filtrar una nota interna, y despublicar no deshace lo ya leído.",
            "examples": [
              "internal"
            ]
          },
          "author_contact_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Persona de contacto del CLIENTE que escribió el comentario (llegó por el portal o por correo); `null` si lo escribió el equipo. Excluyente con `author_id`.",
            "examples": [
              "6f1f9c3e-8f2a-4b6e-9a3d-2b1c0d4e5f60"
            ]
          },
          "channel": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "app",
              "portal",
              "email",
              null
            ],
            "description": "Por dónde VIAJÓ este mensaje entre el equipo y el cliente. El sentido lo da el autor: con `author_contact_id` el mensaje ENTRÓ por ahí, con `author_id` SALIÓ por ahí.\n\n`app` = no salió del producto (nota interna, o respuesta que el cliente solo verá si entra al portal). `portal` = el cliente lo escribió en el portal. `email` = entró por correo, o se envió por correo al solicitante.\n\nEs un hecho del momento en que el mensaje se escribe y NO se puede reconstruir después: un comentario del cliente por portal y otro por correo son idénticos en todo lo demás, y quien atiende necesita saber cuál es cuál para saber dónde va a aterrizar su respuesta. `null` en los comentarios anteriores a la fase 4 — no se inventa un canal para ellos.\n\nLo decide el servidor; NO se admite en la creación (quien escribe elige la VISIBILIDAD, no el transporte).",
            "examples": [
              "email"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-09T12:00:00Z"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-09T12:30:00Z"
            ]
          }
        }
      },
      "Invoice": {
        "type": "object",
        "title": "Invoice",
        "description": "Factura emitida por una organización a un cliente.",
        "required": [
          "id",
          "organization_id",
          "invoice_number",
          "client_name",
          "client_email",
          "status",
          "issue_date",
          "due_date",
          "subtotal",
          "tax_rate",
          "tax_amount",
          "total",
          "currency",
          "notes",
          "cost_center_id",
          "is_intra_eu",
          "items",
          "created_at",
          "updated_at",
          "client_id",
          "client_address",
          "client_tax_id",
          "payment_method",
          "rectifies_invoice_id",
          "rectification_reason"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "5d4c3b2a-1098-4765-bade-f01234567890"
            ]
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          },
          "invoice_number": {
            "type": "string",
            "description": "Identificador legible de la factura (secuencial por organización).",
            "examples": [
              "INV-2026-0042"
            ]
          },
          "client_name": {
            "type": "string",
            "examples": [
              "Tipsterland S.L."
            ]
          },
          "client_email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "description": "Email del cliente; `null` si no se ha indicado.",
            "examples": [
              "facturacion@tipsterland.es"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "sent",
              "paid",
              "overdue",
              "cancelled"
            ]
          },
          "issue_date": {
            "type": "string",
            "format": "date",
            "description": "Fecha de emisión.",
            "examples": [
              "2026-07-01"
            ]
          },
          "due_date": {
            "type": "string",
            "format": "date",
            "description": "Fecha de vencimiento.",
            "examples": [
              "2026-07-31"
            ]
          },
          "subtotal": {
            "type": "number",
            "description": "Suma de los importes de línea antes de impuestos.",
            "examples": [
              750
            ]
          },
          "tax_rate": {
            "type": "number",
            "description": "Tipo impositivo aplicado (p. ej. 21 para 21 %).",
            "examples": [
              21
            ]
          },
          "tax_amount": {
            "type": "number",
            "description": "Importe de impuestos (`subtotal * tax_rate / 100`).",
            "examples": [
              157.5
            ]
          },
          "total": {
            "type": "number",
            "description": "Total a cobrar (`subtotal + tax_amount`).",
            "examples": [
              907.5
            ]
          },
          "currency": {
            "type": "string",
            "description": "Código ISO 4217 de la moneda.",
            "default": "EUR",
            "examples": [
              "EUR"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "description": "Notas libres; `null` si no se han definido.",
            "examples": [
              "Pago a 30 días por transferencia."
            ]
          },
          "cost_center_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Centro de coste al que se imputa la factura; `null` si no se imputa a ninguno. El API lo devuelve en las diez respuestas de factura y el contrato no lo declaraba (PJKT-2323), igual que pasó con `SupplierInvoice` (PJKT-2026): ningún SDK lo veía.",
            "examples": [
              "4a1b2c3d-5e6f-4718-8293-a4b5c6d7e8f9"
            ]
          },
          "is_intra_eu": {
            "type": "boolean",
            "description": "Operación intracomunitaria con inversión del sujeto pasivo (Modelo 349); `false` en la mayoría de facturas. Se servía sin estar declarado (PJKT-2323), así que el SDK no lo veía aunque decida si la factura entra en una declaración informativa.",
            "examples": [
              false
            ]
          },
          "client_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del cliente CRM vinculado; `null` si la factura no está vinculada a un cliente del directorio.",
            "examples": [
              "3fa85f64-5717-4562-b3fc-2c963f66afa6"
            ]
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del proyecto imputado (rentabilidad, mig 0062); `null` si la factura no está asociada a ningún proyecto.",
            "examples": [
              "7c9e6679-7425-40de-944b-e07fc1f90ae7"
            ]
          },
          "client_address": {
            "type": [
              "string",
              "null"
            ],
            "description": "Dirección fiscal del cliente; `null` si no se ha indicado.",
            "examples": [
              "Calle Ejemplo 1, 28001 Madrid"
            ]
          },
          "client_tax_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "NIF/CIF del cliente; `null` si no se ha indicado.",
            "examples": [
              "B12345678"
            ]
          },
          "payment_method": {
            "type": [
              "string",
              "null"
            ],
            "description": "Método de pago preferido (p. ej. \"Transferencia Bancaria\"); `null` si no se ha especificado.",
            "examples": [
              "Transferencia Bancaria"
            ]
          },
          "linked_expense_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Facturación cruzada (mig 0079): UUID del gasto ESPEJO creado automáticamente en la organización vinculada al cliente cuando esta factura se emite (status → `sent`). `null` si el cliente no está vinculado a otra organización o la factura aún no se ha emitido.",
            "examples": [
              "9c8b7a65-4321-4fed-cba9-876543210fed"
            ]
          },
          "rectifies_invoice_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Rectificativa (mig 0172): UUID de la factura que este documento RECTIFICA. `null` en una factura normal. Una rectificativa es una factura más de la serie `REC-`, con `subtotal`, `tax_amount` y `total` en NEGATIVO y estado `paid`: así entra con el signo correcto en el Modelo 303 y en el resto de agregados de ingresos sin que ninguno tenga que conocerla.",
            "examples": [
              "5d4c3b2a-1098-4765-bade-f01234567890"
            ]
          },
          "rectification_reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Motivo del reembolso escrito por quien lo emitió; `null` si no se indicó o si la factura no es una rectificativa.",
            "examples": [
              "Servicio cancelado por el cliente"
            ]
          },
          "items": {
            "type": "array",
            "description": "Líneas de la factura.",
            "items": {
              "$ref": "#/components/schemas/InvoiceItem"
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-01T09:00:00Z"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-01T09:30:00Z"
            ]
          }
        }
      },
      "InvoiceItem": {
        "type": "object",
        "title": "InvoiceItem",
        "description": "Línea de una factura (cantidad, precio unitario e importe calculado).",
        "required": [
          "id",
          "description",
          "quantity",
          "unit_price",
          "amount"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "3f2a1b0c-9d8e-4a7b-8c6d-5e4f3a2b1c0d"
            ]
          },
          "description": {
            "type": "string",
            "examples": [
              "Desarrollo backend — sprint 12"
            ]
          },
          "quantity": {
            "type": "number",
            "description": "Unidades facturadas.",
            "examples": [
              10
            ]
          },
          "unit_price": {
            "type": "number",
            "description": "Precio por unidad (en la moneda de la factura).",
            "examples": [
              75
            ]
          },
          "amount": {
            "type": "number",
            "description": "Importe de la línea (`quantity * unit_price`).",
            "examples": [
              750
            ]
          }
        }
      },
      "InvoiceFromTime": {
        "type": "object",
        "title": "InvoiceFromTime",
        "description": "Parámetros para generar una factura desde el tiempo imputado de un proyecto.",
        "required": [
          "project_id",
          "client_id"
        ],
        "properties": {
          "project_id": {
            "type": "string",
            "format": "uuid",
            "description": "Proyecto cuyo tiempo facturable se agrega.",
            "examples": [
              "7c9e6679-7425-40de-944b-e07fc1f90ae7"
            ]
          },
          "client_id": {
            "type": "string",
            "format": "uuid",
            "description": "Cliente (CRM) al que se factura.",
            "examples": [
              "3fa85f64-5717-4562-b3fc-2c963f66afa6"
            ]
          },
          "date_from": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "`entry_date` >= (fecha ISO 8601); `null` para no acotar el inicio.",
            "examples": [
              "2026-07-01"
            ]
          },
          "date_to": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "`entry_date` <= (fecha ISO 8601); `null` para no acotar el fin.",
            "examples": [
              "2026-07-31"
            ]
          },
          "group_by": {
            "type": "string",
            "description": "Cómo agrupar los registros en líneas de la factura: una línea por `user` (usuario) o por `task` (tarea).",
            "enum": [
              "user",
              "task"
            ],
            "default": "user"
          },
          "tax_rate": {
            "type": "number",
            "description": "Tipo impositivo aplicado a la factura (p. ej. 21 para 21 %).",
            "default": 0,
            "examples": [
              21
            ]
          }
        }
      },
      "Expense": {
        "type": "object",
        "title": "Expense",
        "description": "Gasto registrado por una organización.",
        "required": [
          "id",
          "organization_id",
          "number",
          "category",
          "description",
          "amount",
          "currency",
          "date",
          "vendor",
          "status",
          "notes",
          "cost_center_id",
          "supplier_id",
          "tax_rate",
          "tax_amount",
          "kind",
          "is_intra_eu",
          "created_at",
          "updated_at",
          "has_attachments"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "9c8b7a65-4321-4fed-cba9-876543210fed"
            ]
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          },
          "number": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Número secuencial del gasto dentro de la organización (empieza en 1, nunca se reutiliza). El frontend lo formatea como \"EXP-NNNN\". `null` solo en gastos anteriores al despliegue de la migración 0053.",
            "examples": [
              42
            ]
          },
          "category": {
            "type": "string",
            "enum": [
              "office",
              "travel",
              "software",
              "marketing",
              "payroll",
              "other",
              "hardware",
              "hosting",
              "telecom",
              "subscriptions",
              "professional_services",
              "taxes",
              "insurance",
              "banking",
              "supplies",
              "training",
              "meals",
              "rent",
              "utilities",
              "shipping",
              "legal",
              "advertising",
              "maintenance",
              "fees"
            ]
          },
          "description": {
            "type": "string",
            "examples": [
              "Suscripción anual a Figma"
            ]
          },
          "amount": {
            "type": "number",
            "description": "Importe del gasto (en la moneda indicada).",
            "examples": [
              144
            ]
          },
          "currency": {
            "type": "string",
            "description": "Código ISO 4217 de la moneda.",
            "default": "EUR",
            "examples": [
              "EUR"
            ]
          },
          "date": {
            "type": "string",
            "format": "date",
            "description": "Fecha del gasto.",
            "examples": [
              "2026-07-05"
            ]
          },
          "tax_rate": {
            "type": [
              "number",
              "null"
            ],
            "description": "Tipo de IVA aplicado (p. ej. `21.00`); `null` si el gasto no tiene desglose (exento o sin clasificar). `amount` es siempre el TOTAL con IVA incluido.",
            "examples": [
              21
            ]
          },
          "tax_amount": {
            "type": [
              "number",
              "null"
            ],
            "description": "Cuota de IVA incluida en `amount`; `null` sin desglose. Es lo que suma el Modelo 303 como IVA soportado de gastos (solo gastos aprobados).",
            "examples": [
              10.42
            ]
          },
          "tax_base": {
            "type": [
              "number",
              "null"
            ],
            "description": "Base imponible del gasto (`amount` − `tax_amount`) cuando hay desglose de IVA, como el subtotal de una factura de proveedor; `null` sin desglose. Se persiste en el servidor para exactitud (mig 0086, PM-38). En el formulario se puede capturar base + tipo → total, o total + tipo → base.",
            "examples": [
              51.58
            ]
          },
          "vendor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Proveedor o comercio; `null` si no se ha indicado.",
            "examples": [
              "Figma Inc"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "approved",
              "rejected",
              "paid",
              "void"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "description": "Notas libres; `null` si no se han definido.",
            "examples": [
              "Reembolsable al equipo de diseño."
            ]
          },
          "supplier_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del proveedor vinculado (ficha de la org); `null` si no hay ficha asociada. Al vincular, el campo `vendor` se sincroniza con el nombre del proveedor salvo que se envíe `vendor` explícito en la misma operación.",
            "examples": [
              "3fa85f64-5717-4562-b3fc-2c963f66afa6"
            ]
          },
          "cost_center_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Centro de coste al que se imputa el gasto; `null` si no se imputa a ninguno. El API lo devuelve en las diez respuestas de gasto y el contrato no lo declaraba (PJKT-2323), igual que pasó con `SupplierInvoice` (PJKT-2026): ningún SDK lo veía, así que la pantalla no podía leerlo ni rellenarlo al editar.",
            "examples": [
              "4a1b2c3d-5e6f-4718-8293-a4b5c6d7e8f9"
            ]
          },
          "supplier_invoice_number": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nº/ID de la factura del proveedor asociada al gasto (cuentas a pagar, mig 0086, PM-39 / PJKT-1922): el número que el proveedor imprime en la factura recibida, para conciliar el gasto con su factura. Texto libre (máx. 50 caracteres); `null` si no se ha indicado.",
            "examples": [
              "F-2026/0042"
            ]
          },
          "kind": {
            "type": "string",
            "description": "Discriminador del tipo de gasto (fusión supplier_invoices→expenses, mig 0134, PJKT-2119): `expense` = gasto normal; `supplier_invoice` = factura recibida de proveedor (cuentas a pagar). Los gastos existentes son `expense`. Inmutable tras la creación.",
            "enum": [
              "expense",
              "supplier_invoice"
            ],
            "default": "expense",
            "examples": [
              "expense"
            ]
          },
          "supplier_tax_id": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 20,
            "description": "NIF/CIF del proveedor de la factura recibida (mig 0134); `null` en gastos normales o si no se ha indicado. Máx. 20 caracteres.",
            "examples": [
              "B12345678"
            ]
          },
          "due_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha de vencimiento del pago de la factura de proveedor (mig 0134, AP); `null` si no aplica. La usan los avisos de vencimiento y el AP aging.",
            "examples": [
              "2026-08-31"
            ]
          },
          "is_intra_eu": {
            "type": "boolean",
            "description": "Operación intracomunitaria con inversión del sujeto pasivo (mig 0134, Modelo 349); `false` en la mayoría de gastos.",
            "default": false,
            "examples": [
              false
            ]
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del proyecto imputado (rentabilidad, mig 0062); `null` si el gasto no está asociado a ningún proyecto.",
            "examples": [
              "7c9e6679-7425-40de-944b-e07fc1f90ae7"
            ]
          },
          "source_invoice_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Facturación cruzada (mig 0079): si este gasto se generó automáticamente al emitir una factura de una organización vinculada, apunta a esa factura origen. `null` en gastos normales.",
            "examples": [
              "5d4c3b2a-1098-4765-bade-f01234567890"
            ]
          },
          "source_org_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Facturación cruzada (mig 0079): organización EMISORA de `source_invoice_id` (la que emitió la factura). `null` en gastos normales. Permite sincronizar el estado del gasto de vuelta a la factura de la org emisora.",
            "examples": [
              "0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-05T14:00:00Z"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-05T14:15:00Z"
            ]
          },
          "has_attachments": {
            "type": "boolean",
            "description": "`true` si el gasto tiene al menos un adjunto (factura/justificante). Se calcula en el servidor desde la tabla polimórfica de adjuntos; alimenta el icono de clip y el filtro \"sin factura adjunta\" del listado (PJKT-1839).",
            "examples": [
              true
            ]
          }
        }
      },
      "FinanceSummary": {
        "type": "object",
        "title": "FinanceSummary",
        "description": "Resumen agregado de finanzas de una organización (ingresos, gastos y facturación pendiente) para un rango de fechas, EN UNA SOLA DIVISA: la base de la organización, que viaja en `currency`. Los documentos emitidos en otra divisa quedan fuera de estos agregados a propósito — sin tipo de cambio, sumar euros con dólares no produce un importe, produce un número sin significado (mismo criterio que los agregados de `holdings`, que tampoco convierten).",
        "required": [
          "revenue",
          "expenses",
          "net",
          "outstanding",
          "invoice_count",
          "expense_count",
          "invoices_by_status",
          "criterio"
        ],
        "properties": {
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 en la que están `revenue`, `expenses`, `net` y `outstanding` — la base de la organización. Antes estos importes salían desnudos y el cliente asumía euros.",
            "examples": [
              "USD"
            ]
          },
          "revenue": {
            "type": "number",
            "description": "Ingresos facturados en el rango (total de facturas cobradas) en `currency`.",
            "examples": [
              12500
            ]
          },
          "expenses": {
            "type": "number",
            "description": "Gastos totales en el rango, en `currency`.",
            "examples": [
              4200
            ]
          },
          "net": {
            "type": "number",
            "description": "Resultado neto (`revenue - expenses`), en `currency`.",
            "examples": [
              8300
            ]
          },
          "outstanding": {
            "type": "number",
            "description": "Importe pendiente de cobro (facturas emitidas no cobradas), en `currency`.",
            "examples": [
              2100
            ]
          },
          "invoice_count": {
            "type": "integer",
            "description": "Número de facturas en el rango, TODAS las divisas. Los recuentos no se filtran por `currency` —a diferencia de los importes— porque un recuento no es dinero y hacerlo lo desalinearía del listado de facturas.",
            "examples": [
              18
            ]
          },
          "expense_count": {
            "type": "integer",
            "description": "Número de gastos en el rango, todas las divisas.",
            "examples": [
              34
            ]
          },
          "invoices_by_status": {
            "type": "object",
            "description": "Recuento de facturas por estado (mapa `status` → número), todas las divisas.",
            "additionalProperties": {
              "type": "integer"
            },
            "examples": [
              {
                "draft": 2,
                "sent": 5,
                "paid": 9,
                "overdue": 1,
                "cancelled": 1
              }
            ]
          },
          "criterio": {
            "$ref": "#/components/schemas/CriterioDeCalculo",
            "description": "De dónde salen los cuatro importes de arriba: sus fuentes, su divisa, sus cotas efectivas y cuántos documentos quedaron fuera y por qué. La fuente `factura` declara lo EMITIDO (`sent`+`paid`+`overdue`), que es la unión exacta de lo cobrado (`revenue`) y lo pendiente (`outstanding`) —las dos cifras que este resumen publica por separado—; las dos fuentes de gasto son las dos mitades del coste, con la regla anti-doble-conteo.\n\nSirve además para CUADRAR el número sin abrir ningún listado: los recuentos de esta misma respuesta (`invoice_count`, `expense_count`) cuentan TODAS las divisas, así que restarles los `descartes` de su familia da cuántos documentos suman de verdad. Hasta PJKT-2404 esta respuesta no decía nada de lo que dejaba fuera: un gasto en otra divisa desaparecía del total y el total no lo mencionaba.\n\nUn documento fuera del RANGO no aparece en `descartes`: no está fuera de la cifra, está en otro periodo. Sin rango (`from`/`to` omitidos) las dos cotas viajan a `null` y no hay descarte posible por fecha."
          }
        }
      },
      "CriterioDeCalculo": {
        "type": "object",
        "title": "CriterioDeCalculo",
        "description": "Procedencia de la cifra a la que acompaña: de qué documentos sale, en qué estados, en qué divisa, entre qué fechas, y cuántos documentos quedaron fuera y por qué.\n\nViaja DENTRO de la respuesta, no solo pintado en la pantalla, porque el mismo número lo leen el panel, el MCP y las integraciones: sin esto cada consumidor tenía que adivinar el criterio o repetirlo, y repetirlo es exactamente como «facturado» llegó a valer dos cifras distintas (PJKT-2283). El caso que lo motiva es el doble conteo del Modelo 303: habría sido evidente el primer día si la cifra hubiera dicho de dónde salía.\n\nEs un objeto ACOTADO por construcción —como mucho una `fuente` por familia de documento y un `descarte` por (motivo, documento, estado)—, así que no crece con el uso.\n\nLo que NO trae: un enlace. Abrir la cifra documento a documento ya se puede —`GET /finance/trend/{month}/invoices` devuelve las facturas que suman lo facturado de un mes, con ESTE mismo criterio proyectado a la familia `factura`—, pero la URL no viaja aquí dentro: quien publica el criterio no sabe qué desglose existe para su cifra, y una URL en el payload envejece con cada cambio de rutas. La relación va al revés: el desglose se pide por la identidad de la cifra (el agregado y su celda) y responde con el criterio. De las doce cifras que publican criterio —la tendencia, el aging de cobros, la rentabilidad por proyecto y por cliente, el resumen financiero, el bloque `finance` del panel, el presupuesto vs. real, el rollup de centros de coste, el gasto por categoría, la distribución de facturas por estado, los ingresos por cliente y el aging de pagos—, hoy solo lo facturado de un punto de la tendencia se puede abrir documento a documento; las demás, todavía no.\n\nDÓNDE VIAJA, Y QUÉ CAMBIA ESO. En los informes que devuelven UN objeto (los dos aging, el resumen, el presupuesto vs. real, el rollup) el criterio es del informe entero. En los que devuelven una LISTA de filas (tendencia, gasto por categoría, distribución por estado, ingresos por cliente, rentabilidad por proyecto y por cliente) va en cada fila y describe SU cifra: los descartes de la fila, no los del informe. Eso es lo que hace el aviso accionable —«a esta barra le faltan 700»— y es también lo que le pone un límite: una fila que no existe no puede publicar nada, así que un cubo cuyos documentos están TODOS descartados desaparece con su descarte dentro. En esos seis, «exhaustiva» significa dentro de cada fila. Cada schema de fila lo repite en su propiedad `criterio`.",
        "required": [
          "divisa",
          "desde",
          "hasta",
          "fuentes",
          "descartes"
        ],
        "properties": {
          "divisa": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 en la que está expresada la cifra y, a la vez, la ÚNICA que se suma: lo emitido en otra queda fuera y sale en `descartes` con motivo `otra_divisa`. No se convierte nada (no hay tipos de cambio en el producto). Es la misma que el campo `currency` de la fila; se repite aquí para que el criterio se pueda leer solo.",
            "examples": [
              "EUR"
            ]
          },
          "desde": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Primera fecha que entra en ESTA cifra, ya resuelta: no lo que se pidió, sino la cota que se aplicó. `null` = sin cota inferior (all-time). En una serie mensual es el día 1 del mes RECORTADO por el rango pedido, que es lo que distingue un mes completo de uno que solo cuenta desde el día 15 — el escalón que se lee como una caída del negocio cuando en realidad es media barra.",
            "examples": [
              "2026-03-01"
            ]
          },
          "hasta": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Última fecha que entra en esta cifra, inclusive, con el mismo criterio que `desde`. `null` = sin cota superior.",
            "examples": [
              "2026-03-31"
            ]
          },
          "fuentes": {
            "type": "array",
            "description": "De qué documentos sale la cifra. Una entrada por familia: el margen de un proyecto sale de facturas, gastos, facturas de proveedor y partes de horas, cada una con SUS estados y SU columna de fecha — decir «el rango» sin decir a qué fecha se aplica no es decir nada, porque una factura entra por `issue_date` y su gasto por `date`.",
            "items": {
              "type": "object",
              "title": "FuenteDelCriterio",
              "required": [
                "documento",
                "campo_de_fecha",
                "estados"
              ],
              "properties": {
                "documento": {
                  "type": "string",
                  "enum": [
                    "factura",
                    "gasto",
                    "factura_de_proveedor",
                    "parte_de_horas"
                  ],
                  "description": "Familia de documento que aporta a la cifra. `gasto` y `factura_de_proveedor` viven en la misma tabla desde la fusión de PJKT-2119 y se distinguen por `kind`; se declaran aparte porque su criterio de estado NO es el mismo. `parte_de_horas` (imputaciones de tiempo) no lleva divisa: su coste sale de la tarifa horaria del miembro, un número sin divisa propia, o sea en la moneda de la casa por construcción.",
                  "examples": [
                    "factura"
                  ]
                },
                "campo_de_fecha": {
                  "type": "string",
                  "enum": [
                    "issue_date",
                    "due_date",
                    "date",
                    "entry_date"
                  ],
                  "description": "Columna de fecha del documento con la que se decide si entra en la cifra (y, en un aging, en qué tramo cae). Es el dato que falta para reproducir un total: el mismo rango sobre `issue_date` o sobre `date` da dos números distintos, y ambos son correctos.",
                  "examples": [
                    "issue_date"
                  ]
                },
                "estados": {
                  "type": "array",
                  "description": "Estados de ese documento que SÍ cuentan. Vacía = el estado no filtra esa fuente (las imputaciones de tiempo no tienen estado). Sin `enum` a propósito: cada familia tiene su vocabulario —el de factura y el de gasto comparten `paid` y nada más—, así que un enum único aquí declararía como válido para una lo que solo vale para la otra.",
                  "items": {
                    "type": "string"
                  },
                  "examples": [
                    [
                      "sent",
                      "paid",
                      "overdue"
                    ]
                  ]
                }
              }
            }
          },
          "descartes": {
            "type": "array",
            "description": "Cuántos documentos quedaron fuera de la cifra y por qué. Solo aparecen los grupos con al menos un documento: publicar «0 en otra divisa» solo enseña a ignorar el aviso. Es la misma coletilla que la pantalla ya dice en los listados («3 en otra divisa, sin sumar»), y lo mismo que el Modelo 303 publica desde PJKT-2280, servido de forma uniforme para que no haya que leerlo en la interfaz para saberlo.\n\nUn grupo por (`motivo`, `documento`, `estado`) —UNO, nunca dos: los censos agrupan también por divisa, y si esos grupos no se fusionaran, una organización con facturas en dólares y en libras publicaría dos entradas `otra_divisa` idénticas de leer y quien pintara la primera enseñaría la mitad del recuento—, y la partición es EXHAUSTIVA: todo documento de las familias de `fuentes` que caiga en el rango está o en la cifra o en exactamente un descarte. Eso es lo que permite cuadrar el total contra el listado sin abrirlo — y es la mitad de un cuadre automático (la segunda pieza de la propuesta) hecha con lo que ya se sabe aquí.\n\nEn los informes que devuelven una lista de filas, «exhaustiva» se aplica DENTRO de la fila (ver la descripción de este objeto).",
            "items": {
              "type": "object",
              "title": "DescarteDelCriterio",
              "required": [
                "motivo",
                "documento",
                "estado",
                "documentos",
                "importe"
              ],
              "properties": {
                "motivo": {
                  "type": "string",
                  "enum": [
                    "otra_divisa",
                    "estado_excluido",
                    "duplica_factura_ap",
                    "sin_linea_de_presupuesto",
                    "sin_centro_de_coste"
                  ],
                  "description": "Por qué esos documentos no están en la cifra. Los cinco motivos son EXCLUYENTES y se evalúan en este orden, así que cada documento del rango aparece en un descarte como máximo (o en la cifra): `otra_divisa` = emitidos en una divisa distinta de `divisa`, y no se convierte nada — se mira primero porque un total de monedas mezcladas no se puede publicar ni dentro de un aviso; `estado_excluido` = están en `divisa` pero su estado no entra en el criterio de la fuente; `duplica_factura_ap` = están en `divisa` y en un estado que cuenta, pero son un gasto normal que anota una factura de proveedor EXISTENTE y la regla anti-doble-conteo del coste lo descarta por ello. Este último es el hermano del hallazgo que abrió la propuesta: el Modelo 303 sumaba DOS veces el IVA de esa misma pareja de documentos y nadie podía verlo desde el producto. Dice lo que la regla hace y no más: la regla solo comprueba que la factura EXISTA, no que entre en este mismo rango y divisa, así que si la factura cae fuera, el importe no lo suma ninguno de los dos. Esa asimetría está documentada en `finance/cost_rules.py`; el criterio la deja a la vista en vez de afirmar que el importe está contado en otro sitio; `sin_linea_de_presupuesto` = están en `divisa` y en un estado que cuenta, pero su categoría no tiene línea de presupuesto en el período (o la única que hay es de otro proyecto), así que no hay casilla del informe a la que sumarlos; `sin_centro_de_coste` = están en `divisa` y en un estado que cuenta, pero no están imputados a ningún centro de coste, y el rollup agrupa POR centro: sin centro no hay fila.\n\nLOS DOS ÚLTIMOS SON DOS Y NO UNO, A PROPÓSITO. Comparten forma —el documento pasa la divisa y pasa el estado, y aun así no entra— pero no comparten qué falta ni qué se hace al respecto: en `sin_linea_de_presupuesto` lo que falta está en la CONFIGURACIÓN del informe (nadie presupuestó esa categoría) y en `sin_centro_de_coste` está en el DOCUMENTO (nadie le puso centro). Plegarlos en un motivo genérico obligaría a quien lee el criterio a deducir cuál de los dos es por el endpoint del que vino, y deducir el criterio de una cifra es exactamente lo que esta pieza existe para eliminar. Ese es también el precedente de `duplica_factura_ap`, que nombra su regla concreta en vez de llamarse `regla_de_negocio`.\n\nY NINGUNO DE LOS DOS ES «FUERA DE RANGO». Un documento de otro período no es un descarte —es otro período, y por eso `descartes` no tiene ese motivo—: se ve pidiendo el rango que le toca. Estos, en cambio, están DENTRO del rango, de la divisa y del estado que la cifra dice contar, y no aparecen en ninguna parte de la respuesta. Por eso son un descarte y no un silencio legítimo.\n\nY NO SON UN ERROR DEL USUARIO. Presupuestar todas las categorías no es obligatorio, e imputar cada documento a un centro de coste tampoco: un gasto sin presupuesto es un gasto perfectamente normal. Lo que el criterio afirma es que ESA CIFRA no lo cuenta, no que alguien se haya equivocado, y el texto que se enseñe encima tiene que informar en esos términos.",
                  "examples": [
                    "otra_divisa"
                  ]
                },
                "documento": {
                  "type": "string",
                  "enum": [
                    "factura",
                    "gasto",
                    "factura_de_proveedor",
                    "parte_de_horas"
                  ],
                  "description": "Familia del documento descartado, con los mismos valores que `fuentes[].documento`. Va por familia y no en un único total porque «5 documentos fuera» sin decir de qué tipo no se puede comprobar.",
                  "examples": [
                    "factura"
                  ]
                },
                "estado": {
                  "type": "string",
                  "description": "Estado de los documentos descartados. NINGÚN descarte mezcla dos estados, ni siquiera cuando el motivo no habla del estado: «12 facturas en otra divisa» esconde si son doce borradores o doce emitidas sin cobrar, que es la diferencia entre un aviso y un problema.",
                  "examples": [
                    "draft"
                  ]
                },
                "documentos": {
                  "type": "integer",
                  "description": "Cuántos documentos quedaron fuera por este motivo.",
                  "examples": [
                    3
                  ]
                },
                "importe": {
                  "type": [
                    "number",
                    "null"
                  ],
                  "description": "Suma de lo descartado, en `divisa`, cuando sumarlo significa algo. `null` cuando NO lo significa, que hoy es exactamente el motivo `otra_divisa`: esos documentos están en monedas distintas y sumarlos daría el mismo número inventado que esta pieza vino a evitar. El recuento sí es siempre válido.\n\nLos otros cuatro motivos SÍ traen importe, y no por simetría: el orden de evaluación pone `otra_divisa` primero, así que todo lo que llega a un motivo posterior está ya en `divisa` y su suma es dinero de verdad. En `sin_linea_de_presupuesto` y `sin_centro_de_coste` ese importe es además la respuesta a la pregunta que la cifra provoca —cuánto gasto real no está mirando ningún presupuesto, cuánto no está imputado a ningún departamento—, y publicarlo a `null` habría convertido el aviso en un acertijo.",
                  "examples": [
                    1250.5
                  ]
                }
              }
            }
          }
        }
      },
      "FinanceTrendPoint": {
        "type": "object",
        "title": "FinanceTrendPoint",
        "description": "Ingresos y gastos agregados de un mes concreto. `income` suma los totales de facturas emitidas/cobradas (estados `sent`, `paid`, `overdue`) por `issue_date`; `expenses` suma el coste del mes por `date` — gastos normales aprobados y facturas de proveedor recibidas, el mismo criterio que `expenses` de `FinanceSummary`.",
        "required": [
          "month",
          "income",
          "expenses",
          "criterio"
        ],
        "properties": {
          "month": {
            "type": "string",
            "description": "Mes en formato `YYYY-MM`.",
            "examples": [
              "2026-03"
            ]
          },
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 de los importes de esta fila — la base de la organización. El agregado suma SOLO documentos emitidos en ella: antes era un `SUM` ciego a la divisa, es decir euros y dólares en un mismo número, dibujado sin ninguna etiqueta que lo dijera. No se convierte nada (no hay tipos de cambio en el producto). El recuento de esta fila también se acota a esta divisa, porque dice de cuántos documentos sale ESTE importe.",
            "examples": [
              "USD"
            ]
          },
          "income": {
            "type": "number",
            "description": "Total facturado con ingreso en el mes.",
            "examples": [
              12500
            ]
          },
          "expenses": {
            "type": "number",
            "description": "Coste del mes: gastos normales aprobados + facturas de proveedor recibidas (todo estado salvo `rejected`/`void`), descontando el gasto que duplica una factura de proveedor existente. Antes solo contaba la mitad de gastos normales, así que la serie sumaba menos que el resumen y el chip de variación comparaba dos números incompletos.",
            "examples": [
              4200
            ]
          },
          "criterio": {
            "$ref": "#/components/schemas/CriterioDeCalculo"
          }
        }
      },
      "DesgloseDeLoFacturado": {
        "type": "object",
        "title": "DesgloseDeLoFacturado",
        "description": "Los documentos que suman el `income` de un punto de `GET /finance/trend`, con su importe, paginados, y con el MISMO criterio con el que se calculó el total.\n\nPor qué existe y no basta con enlazar al listado de facturas con filtros: el criterio de esta cifra es `status ∈ (sent, paid, overdue)` **y** divisa base **y** `issue_date` dentro del mes RECORTADO por el rango pedido, y `GET /invoices` acepta un solo `status` y ningún filtro de divisa. Un enlace filtrado enseñaría un conjunto distinto del que el total suma —y para saber cuánto suma habría que sumar la página en el cliente, que es una segunda definición de la cifra: exactamente cómo «facturado» llegó a valer dos números distintos (PJKT-2283)—. Aquí el desglose y el total salen de la misma consulta.\n\nLo que este desglose NO es: la pantalla de facturas. No trae líneas, ni acciones, ni filtros. Es la PRUEBA de un número: cada fila lleva lo justo para reconocer el documento y su importe, y su `id` para abrir la factura de verdad.",
        "required": [
          "month",
          "currency",
          "income",
          "total_invoices",
          "invoices",
          "criterio"
        ],
        "properties": {
          "month": {
            "type": "string",
            "description": "Mes `YYYY-MM` del punto de la tendencia que se está abriendo. Se llama igual que `FinanceTrendPoint.month` porque es el mismo punto.",
            "examples": [
              "2026-03"
            ]
          },
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 de `income` y de los importes de las filas — la base de la organización, la misma que `criterio.divisa`. Ninguna fila puede venir en otra divisa: lo emitido en otra no está en la cifra, está en `criterio.descartes` con motivo `otra_divisa`. Por eso las filas NO repiten su divisa: repetirla invitaría a pintar una fila con una moneda distinta de la del total.",
            "examples": [
              "EUR"
            ]
          },
          "income": {
            "type": "number",
            "description": "Lo facturado del mes: el MISMO número que `income` del punto de `GET /finance/trend` con este `from`/`to`. Se llama igual a propósito —el cuadre que hay que poder comprobar es `desglose.income == punto.income`— y NO se recalcula sumando la página: sale de la misma consulta agregada que sirve la tendencia, así que paginar no lo mueve.",
            "examples": [
              12500
            ]
          },
          "total_invoices": {
            "type": "integer",
            "description": "Cuántas facturas cumplen el criterio, SIN paginar: el tamaño del conjunto que suma `income`, no el de la página. No es el `invoice_count` de `FinanceSummary`, que cuenta TODAS las facturas del periodo en cualquier estado y cualquier divisa.",
            "examples": [
              213
            ]
          },
          "invoices": {
            "type": "array",
            "description": "La página de facturas, ordenada por `issue_date` descendente y con desempate por `id` — un orden total, que es lo que hace que paginar no salte ni repita filas.",
            "items": {
              "type": "object",
              "title": "FacturaDelDesglose",
              "description": "Una factura que aporta a la cifra, con lo justo para reconocerla y comprobar su importe.",
              "required": [
                "id",
                "invoice_number",
                "client_name",
                "issue_date",
                "status",
                "total"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid",
                  "description": "Id de la factura, para abrirla (`GET /invoices/{invoice_id}`).",
                  "examples": [
                    "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                  ]
                },
                "invoice_number": {
                  "type": "string",
                  "description": "Identificador legible (secuencial por organización).",
                  "examples": [
                    "INV-2026-0042"
                  ]
                },
                "client_name": {
                  "type": "string",
                  "description": "Nombre del cliente tal como quedó en la factura al emitirla (snapshot fiscal); puede no coincidir con la ficha si se renombró después.",
                  "examples": [
                    "Tipsterland S.L."
                  ]
                },
                "issue_date": {
                  "type": "string",
                  "format": "date",
                  "description": "Fecha de emisión, que es la columna con la que esta cifra decide si la factura entra (`criterio.fuentes[].campo_de_fecha`). Va en la fila para que se pueda comprobar contra las cotas del criterio.",
                  "examples": [
                    "2026-03-10"
                  ]
                },
                "status": {
                  "type": "string",
                  "enum": [
                    "sent",
                    "paid",
                    "overdue"
                  ],
                  "description": "Estado de la factura. Solo aparecen los tres que cuentan como ingreso devengado, los mismos de `criterio.fuentes[].estados`: un borrador o una anulada no están aquí, están en `criterio.descartes` con motivo `estado_excluido`.",
                  "examples": [
                    "sent"
                  ]
                },
                "total": {
                  "type": "number",
                  "description": "Total de la factura, en `currency`: lo que esta fila aporta a `income`. Es el mismo `total` de `Invoice`.",
                  "examples": [
                    907.5
                  ]
                }
              }
            }
          },
          "criterio": {
            "$ref": "#/components/schemas/CriterioDeCalculo"
          }
        }
      },
      "ExpenseCategoryStat": {
        "type": "object",
        "title": "ExpenseCategoryStat",
        "description": "Agregado de gastos por categoría (para gráfico de gasto por categoría).",
        "required": [
          "category",
          "total",
          "count",
          "criterio"
        ],
        "properties": {
          "category": {
            "type": "string",
            "enum": [
              "office",
              "travel",
              "software",
              "marketing",
              "payroll",
              "other",
              "hardware",
              "hosting",
              "telecom",
              "subscriptions",
              "professional_services",
              "taxes",
              "insurance",
              "banking",
              "supplies",
              "training",
              "meals",
              "rent",
              "utilities",
              "shipping",
              "legal",
              "advertising",
              "maintenance",
              "fees"
            ]
          },
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 de los importes de esta fila — la base de la organización. El agregado suma SOLO documentos emitidos en ella: antes era un `SUM` ciego a la divisa, es decir euros y dólares en un mismo número, dibujado sin ninguna etiqueta que lo dijera. No se convierte nada (no hay tipos de cambio en el producto). El recuento de esta fila también se acota a esta divisa, porque dice de cuántos documentos sale ESTE importe.",
            "examples": [
              "USD"
            ]
          },
          "total": {
            "type": "number",
            "description": "Suma de importes de la categoría.",
            "examples": [
              150
            ]
          },
          "count": {
            "type": "integer",
            "description": "Número de gastos de la categoría.",
            "examples": [
              2
            ]
          },
          "criterio": {
            "$ref": "#/components/schemas/CriterioDeCalculo"
          }
        }
      },
      "InvoiceStatusStat": {
        "type": "object",
        "title": "InvoiceStatusStat",
        "description": "Distribución de facturas por estado (para gráfico de estados).",
        "required": [
          "status",
          "count",
          "total",
          "criterio"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "sent",
              "paid",
              "overdue",
              "cancelled"
            ]
          },
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 de los importes de esta fila — la base de la organización. El agregado suma SOLO documentos emitidos en ella: antes era un `SUM` ciego a la divisa, es decir euros y dólares en un mismo número, dibujado sin ninguna etiqueta que lo dijera. No se convierte nada (no hay tipos de cambio en el producto). El recuento de esta fila también se acota a esta divisa, porque dice de cuántos documentos sale ESTE importe.",
            "examples": [
              "USD"
            ]
          },
          "count": {
            "type": "integer",
            "description": "Número de facturas en ese estado.",
            "examples": [
              5
            ]
          },
          "total": {
            "type": "number",
            "description": "Suma de totales de las facturas en ese estado.",
            "examples": [
              7500
            ]
          },
          "criterio": {
            "$ref": "#/components/schemas/CriterioDeCalculo"
          }
        }
      },
      "AgingReport": {
        "type": "object",
        "title": "AgingReport",
        "description": "Aging de cuentas por cobrar: facturas emitidas y no cobradas (`sent`, `overdue`) troceadas por antigüedad de la deuda, con los mayores deudores.",
        "required": [
          "buckets",
          "top_debtors",
          "total_outstanding",
          "criterio"
        ],
        "properties": {
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 de todos los importes del informe — la base de la organización. Solo se agregan facturas emitidas en ella: «lo que te deben a más de 90 días» sumando euros con dólares no es una cifra que se pueda ir a cobrar. La deuda en otra divisa queda fuera; no se convierte nada.",
            "examples": [
              "USD"
            ]
          },
          "buckets": {
            "type": "array",
            "description": "Tramos de antigüedad; siempre los cuatro, con 0 si están vacíos.",
            "items": {
              "type": "object",
              "required": [
                "label",
                "count",
                "total"
              ],
              "properties": {
                "label": {
                  "type": "string",
                  "description": "Etiqueta del tramo de días de retraso.",
                  "enum": [
                    "0-30",
                    "31-60",
                    "61-90",
                    "90+"
                  ]
                },
                "count": {
                  "type": "integer",
                  "examples": [
                    3
                  ]
                },
                "total": {
                  "type": "number",
                  "examples": [
                    4200
                  ]
                }
              }
            }
          },
          "top_debtors": {
            "type": "array",
            "description": "Hasta 5 clientes con más deuda pendiente, de mayor a menor.",
            "items": {
              "type": "object",
              "required": [
                "client_name",
                "outstanding",
                "count"
              ],
              "properties": {
                "client_name": {
                  "type": "string",
                  "examples": [
                    "Tipsterland S.L."
                  ]
                },
                "outstanding": {
                  "type": "number",
                  "description": "Importe pendiente de cobro del cliente.",
                  "examples": [
                    3200
                  ]
                },
                "count": {
                  "type": "integer",
                  "description": "Número de facturas pendientes del cliente.",
                  "examples": [
                    2
                  ]
                }
              }
            }
          },
          "total_outstanding": {
            "type": "number",
            "description": "Suma de todos los tramos (deuda total pendiente de cobro).",
            "examples": [
              6400
            ]
          },
          "criterio": {
            "$ref": "#/components/schemas/CriterioDeCalculo"
          }
        }
      },
      "RevenueByClientRow": {
        "type": "object",
        "title": "RevenueByClientRow",
        "description": "Agregado de ingresos por cliente para el rango consultado. Agrupa por la ficha de cliente vinculada (client_id) cuando existe; si no hay ficha, agrupa por el snapshot de client_name de la factura. Solo incluye facturas en estado sent, paid u overdue.",
        "required": [
          "client_id",
          "client_name",
          "invoice_count",
          "total",
          "criterio"
        ],
        "properties": {
          "client_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID de la ficha de cliente (CRM) vinculada; `null` para facturas sin cliente vinculado (agrupadas por snapshot de nombre).",
            "examples": [
              "9c8b7a65-4321-4fed-cba9-876543210fed"
            ]
          },
          "client_name": {
            "type": "string",
            "description": "Nombre del cliente. Si hay ficha vinculada, corresponde al snapshot almacenado en la factura (no al nombre actual de la ficha).",
            "examples": [
              "Acme SL"
            ]
          },
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 de los importes de esta fila — la base de la organización. El agregado suma SOLO documentos emitidos en ella: antes era un `SUM` ciego a la divisa, es decir euros y dólares en un mismo número, dibujado sin ninguna etiqueta que lo dijera. No se convierte nada (no hay tipos de cambio en el producto). El recuento de esta fila también se acota a esta divisa, porque dice de cuántos documentos sale ESTE importe.",
            "examples": [
              "USD"
            ]
          },
          "invoice_count": {
            "type": "integer",
            "description": "Número de facturas del cliente en el rango.",
            "examples": [
              3
            ]
          },
          "total": {
            "type": "number",
            "description": "Suma de totales de facturas del cliente en el rango.",
            "examples": [
              4500
            ]
          },
          "criterio": {
            "$ref": "#/components/schemas/CriterioDeCalculo"
          }
        }
      },
      "ProjectProfitability": {
        "type": "object",
        "title": "ProjectProfitability",
        "description": "Rentabilidad de un proyecto en el rango consultado. `revenue` suma los totales de facturas emitidas (`sent`/`paid`/`overdue`) imputadas al proyecto; `cost_labor` es el coste de las horas registradas y `cost_expenses` el de los gastos aprobados imputados. `cost_total` = mano de obra + gastos; `margin` = ingresos − coste total; `margin_pct` es el margen sobre ingresos (porcentaje) o `null` cuando no hay ingresos. Un proyecto en la papelera sigue apareciendo, con sus cifras y con `deleted: true`.",
        "required": [
          "project_id",
          "project_name",
          "parent_id",
          "deleted",
          "revenue",
          "cost_labor",
          "cost_expenses",
          "cost_total",
          "margin",
          "margin_pct",
          "billable_hours",
          "total_hours",
          "criterio"
        ],
        "properties": {
          "project_id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID del proyecto.",
            "examples": [
              "9c8b7a65-4321-4fed-cba9-876543210fed"
            ]
          },
          "project_name": {
            "type": "string",
            "description": "Nombre del proyecto.",
            "examples": [
              "Rediseño web Acme"
            ]
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "El proyecto del que cuelga éste, o `null` si es raíz.\n\n**Las cifras de esta fila son SOLO suyas**: no incluyen las de sus subproyectos. Sumar el árbol es de quien pinta, y este campo es lo que se lo permite. Se decidió así en vez de mandar además unos `*_tree`: serían siete importes duplicados por fila, y el día que los dos juegos se calcularan distinto nadie sabría cuál mira la pantalla.\n\nPara que ese árbol se pueda construir, un proyecto que es ANCESTRO de otro con actividad sale SIEMPRE, con ceros si no tiene nada imputado: sin su fila, el subsistema colgaría de nadie.",
            "examples": [
              "3f2b7739-29b6-4cc8-af3d-982cae79996d"
            ]
          },
          "deleted": {
            "type": "boolean",
            "description": "El proyecto está en la papelera (borrado lógico). La fila sigue en el informe con todas sus cifras: borrar un proyecto no reescribe un periodo ya cerrado —las horas y los gastos se pagaron igual—, así que el margen del rango no puede mejorar solo porque se limpie el tablero. El flag existe para que la pantalla pueda avisar de que ese proyecto ya no aparece en la lista de proyectos. Un proyecto ARCHIVADO no está borrado y devuelve `false`.",
            "examples": [
              false
            ]
          },
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 de los cinco importes de la fila — la base de la organización. Aquí no es cosmética: `cost_labor` sale de la tarifa horaria del miembro (`hourly_cost_rate`), un número SIN divisa propia, es decir en la moneda de la casa por construcción; restarle un ingreso facturado en otra divisa producía un margen inventado. Ingresos, gastos y AP se acotan a esta divisa; lo emitido en otra queda fuera. No se convierte nada.",
            "examples": [
              "USD"
            ]
          },
          "revenue": {
            "type": "number",
            "description": "Ingresos facturados imputados al proyecto en el rango.",
            "examples": [
              12500
            ]
          },
          "cost_labor": {
            "type": "number",
            "description": "Coste de las horas registradas en el proyecto en el rango.",
            "examples": [
              6400
            ]
          },
          "cost_expenses": {
            "type": "number",
            "description": "Coste de los gastos aprobados imputados al proyecto en el rango.",
            "examples": [
              1200
            ]
          },
          "cost_total": {
            "type": "number",
            "description": "Coste total (mano de obra + gastos).",
            "examples": [
              7600
            ]
          },
          "margin": {
            "type": "number",
            "description": "Margen (ingresos − coste total).",
            "examples": [
              4900
            ]
          },
          "margin_pct": {
            "type": [
              "number",
              "null"
            ],
            "description": "Margen sobre ingresos (porcentaje); `null` cuando no hay ingresos en el rango.",
            "examples": [
              39.2
            ]
          },
          "billable_hours": {
            "type": "number",
            "description": "Horas facturables registradas en el proyecto en el rango.",
            "examples": [
              120
            ]
          },
          "total_hours": {
            "type": "number",
            "description": "Total de horas registradas en el proyecto en el rango.",
            "examples": [
              160
            ]
          },
          "criterio": {
            "$ref": "#/components/schemas/CriterioDeCalculo"
          }
        }
      },
      "ClientProfitability": {
        "type": "object",
        "title": "ClientProfitability",
        "description": "Rentabilidad de un cliente en el rango consultado: suma de `revenue` y `cost` (= `cost_total` de `ProjectProfitability`, ya con AP/P-6 incluida) de todos sus proyectos con actividad. `margin` = `revenue` − `cost`; `project_count` cuenta los proyectos que aportaron a la fila. Los proyectos sin cliente vinculado se agrupan en una fila sintética con `client_id: null` y `client_name: \"Sin cliente\"` en vez de excluirse. Un cliente sin proyectos con actividad en el rango no aparece.",
        "required": [
          "client_id",
          "client_name",
          "revenue",
          "cost",
          "margin",
          "project_count",
          "criterio"
        ],
        "properties": {
          "client_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del cliente, o `null` para la fila sintética de proyectos sin cliente vinculado (\"Sin cliente\").",
            "examples": [
              "3fa85f64-5717-4562-b3fc-2c963f66afa6"
            ]
          },
          "client_name": {
            "type": "string",
            "description": "Nombre del cliente; \"Sin cliente\" para proyectos sin cliente vinculado; \"Cliente eliminado\" si la ficha fue archivada pero algún proyecto todavía referencia su id.",
            "examples": [
              "Acme Corp"
            ]
          },
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 de `revenue`, `cost` y `margin` — la base de la organización, heredada de las filas por proyecto que se agregan aquí. No se convierte nada.",
            "examples": [
              "USD"
            ]
          },
          "revenue": {
            "type": "number",
            "description": "Ingresos facturados de todos los proyectos del cliente en el rango.",
            "examples": [
              22500
            ]
          },
          "cost": {
            "type": "number",
            "description": "Coste total (mano de obra + gastos + AP) de todos los proyectos del cliente.",
            "examples": [
              9600
            ]
          },
          "margin": {
            "type": "number",
            "description": "Margen (revenue − cost).",
            "examples": [
              12900
            ]
          },
          "project_count": {
            "type": "integer",
            "description": "Número de proyectos del cliente con actividad en el rango.",
            "examples": [
              2
            ]
          },
          "criterio": {
            "$ref": "#/components/schemas/CriterioDeCalculo"
          }
        }
      },
      "TeamUtilization": {
        "type": "object",
        "title": "TeamUtilization",
        "description": "Utilización de un miembro del equipo en el rango consultado. `utilization_pct` es la proporción de horas facturables sobre el total de horas registradas (porcentaje).",
        "required": [
          "user_id",
          "name",
          "billable_hours",
          "total_hours",
          "utilization_pct"
        ],
        "properties": {
          "user_id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID del usuario.",
            "examples": [
              "9c8b7a65-4321-4fed-cba9-876543210fed"
            ]
          },
          "name": {
            "type": "string",
            "description": "Nombre del miembro del equipo.",
            "examples": [
              "Ana García"
            ]
          },
          "billable_hours": {
            "type": "number",
            "description": "Horas facturables registradas en el rango.",
            "examples": [
              120
            ]
          },
          "total_hours": {
            "type": "number",
            "description": "Total de horas registradas en el rango.",
            "examples": [
              160
            ]
          },
          "utilization_pct": {
            "type": "number",
            "description": "Horas facturables sobre el total (porcentaje).",
            "examples": [
              75
            ]
          }
        }
      },
      "Capacity": {
        "type": "object",
        "title": "Capacity",
        "description": "Conciliación de capacidad de una organización para una semana (7 días desde el `week_start_day` de la organización): una fila por miembro con horas esperadas, festivos, ausencias, capacidad neta, imputado y delta.",
        "required": [
          "week_start",
          "week_end",
          "rows"
        ],
        "properties": {
          "week_start": {
            "type": "string",
            "format": "date",
            "description": "Primer día de la semana según `week_start_day` de la organización (`0` lunes, `5` sábado, `6` domingo). La fecha de entrada se normaliza a él.",
            "examples": [
              "2026-08-03"
            ]
          },
          "week_end": {
            "type": "string",
            "format": "date",
            "description": "Último día de la semana (`week_start` + 6 días).",
            "examples": [
              "2026-08-09"
            ]
          },
          "rows": {
            "type": "array",
            "description": "Filas de conciliación, una por miembro de la organización.",
            "items": {
              "$ref": "#/components/schemas/CapacityRow"
            }
          }
        }
      },
      "CapacityRow": {
        "type": "object",
        "title": "CapacityRow",
        "description": "Conciliación de capacidad de un miembro para la semana consultada: horas esperadas (modelo híbrido), festivos y ausencias que descuentan, capacidad neta, horas imputadas y delta (imputado − neto).",
        "required": [
          "user_id",
          "name",
          "expected_hours",
          "expected_source",
          "holiday_hours",
          "absence_hours",
          "net_expected_hours",
          "logged_hours",
          "delta_hours",
          "has_override",
          "conflict_days",
          "has_conflict"
        ],
        "properties": {
          "user_id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID del usuario miembro."
          },
          "name": {
            "type": "string",
            "examples": [
              "Nick Valdivia"
            ]
          },
          "employee_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID de la ficha employee vinculada por email; `null` si no hay ficha."
          },
          "expected_hours": {
            "type": "number",
            "description": "Horas esperadas brutas de la semana según el modelo híbrido, antes de descontar festivos y ausencias.",
            "examples": [
              40
            ]
          },
          "expected_source": {
            "type": "string",
            "description": "Escalón del modelo híbrido que fijó las horas esperadas, del más específico al más general. `override` = memberships.expected_hours_per_week; `schedule` = horario del empleado; `department` = horario del departamento; `org` = horario por defecto de la organización (default_weekly_schedule); `none` = sin base de horario en ningún nivel.",
            "enum": [
              "override",
              "schedule",
              "department",
              "org",
              "none"
            ]
          },
          "holiday_hours": {
            "type": "number",
            "description": "Horas descontadas por festivos (del departamento y de la organización) que caen en la semana (solo si el festivo cae en un día laborable del horario).",
            "examples": [
              8
            ]
          },
          "absence_hours": {
            "type": "number",
            "description": "Horas descontadas por ausencias aprobadas que solapan la semana, prorrateadas por día laborable.",
            "examples": [
              16
            ]
          },
          "net_expected_hours": {
            "type": "number",
            "description": "Capacidad neta: max(0, esperadas − festivos − ausencias).",
            "examples": [
              32
            ]
          },
          "logged_hours": {
            "type": "number",
            "description": "Horas imputadas (time_entries) del miembro esa semana.",
            "examples": [
              30
            ]
          },
          "delta_hours": {
            "type": "number",
            "description": "`logged − net_expected`. Negativo = faltan horas; positivo = exceso.",
            "examples": [
              -2
            ]
          },
          "has_override": {
            "type": "boolean",
            "description": "`true` si el override manual (memberships.expected_hours_per_week) está fijado para este miembro."
          },
          "conflict_days": {
            "type": "array",
            "description": "Días de la semana (ISO `YYYY-MM-DD`) en los que el miembro tiene una ausencia APROBADA que solapa Y además ha imputado tiempo ese día — una incoherencia (no se debería fichar durante una ausencia aprobada).",
            "items": {
              "type": "string",
              "format": "date"
            },
            "examples": [
              [
                "2026-08-04"
              ]
            ]
          },
          "has_conflict": {
            "type": "boolean",
            "description": "`true` si `conflict_days` no está vacío."
          }
        }
      },
      "OrgSchedule": {
        "type": "object",
        "title": "OrgSchedule",
        "description": "Horario laboral por defecto de la organización. Sirve de base (nivel más general) del modelo híbrido de capacidad: se hereda cuando el miembro no tiene override, ni horario propio, ni horario de departamento.",
        "required": [
          "weekly_schedule"
        ],
        "properties": {
          "weekly_schedule": {
            "type": [
              "object",
              "null"
            ],
            "description": "Horario laboral semanal por defecto. Cada clave es un día de la semana (`mon`–`sun`); el valor es `null` (no laboral) o `{start: \"HH:MM\", end: \"HH:MM\"}`. `null` = la organización no tiene horario por defecto.",
            "properties": {
              "mon": {
                "$ref": "#/components/schemas/DaySchedule"
              },
              "tue": {
                "$ref": "#/components/schemas/DaySchedule"
              },
              "wed": {
                "$ref": "#/components/schemas/DaySchedule"
              },
              "thu": {
                "$ref": "#/components/schemas/DaySchedule"
              },
              "fri": {
                "$ref": "#/components/schemas/DaySchedule"
              },
              "sat": {
                "$ref": "#/components/schemas/DaySchedule"
              },
              "sun": {
                "$ref": "#/components/schemas/DaySchedule"
              }
            },
            "definitions": {
              "DaySchedule": {
                "oneOf": [
                  {
                    "type": "null"
                  },
                  {
                    "type": "object",
                    "required": [
                      "start",
                      "end"
                    ],
                    "properties": {
                      "start": {
                        "type": "string",
                        "pattern": "^\\d{2}:\\d{2}$",
                        "examples": [
                          "09:00"
                        ]
                      },
                      "end": {
                        "type": "string",
                        "pattern": "^\\d{2}:\\d{2}$",
                        "examples": [
                          "17:00"
                        ]
                      }
                    }
                  }
                ]
              }
            }
          }
        }
      },
      "OrgScheduleUpdate": {
        "type": "object",
        "title": "OrgScheduleUpdate",
        "description": "Fija (reemplaza) el horario laboral por defecto de la organización. Requiere manager+. `weekly_schedule: null` borra el horario por defecto.",
        "required": [
          "weekly_schedule"
        ],
        "properties": {
          "weekly_schedule": {
            "type": [
              "object",
              "null"
            ],
            "description": "Horario semanal por defecto. Claves `mon`–`sun`; valor `null` (no laboral) o `{start: \"HH:MM\", end: \"HH:MM\"}`. `null` (o el objeto vacío) deja la organización sin horario por defecto.",
            "properties": {
              "mon": {
                "$ref": "#/components/schemas/DaySchedule"
              },
              "tue": {
                "$ref": "#/components/schemas/DaySchedule"
              },
              "wed": {
                "$ref": "#/components/schemas/DaySchedule"
              },
              "thu": {
                "$ref": "#/components/schemas/DaySchedule"
              },
              "fri": {
                "$ref": "#/components/schemas/DaySchedule"
              },
              "sat": {
                "$ref": "#/components/schemas/DaySchedule"
              },
              "sun": {
                "$ref": "#/components/schemas/DaySchedule"
              }
            },
            "definitions": {
              "DaySchedule": {
                "oneOf": [
                  {
                    "type": "null"
                  },
                  {
                    "type": "object",
                    "required": [
                      "start",
                      "end"
                    ],
                    "properties": {
                      "start": {
                        "type": "string",
                        "pattern": "^\\d{2}:\\d{2}$",
                        "examples": [
                          "09:00"
                        ]
                      },
                      "end": {
                        "type": "string",
                        "pattern": "^\\d{2}:\\d{2}$",
                        "examples": [
                          "17:00"
                        ]
                      }
                    }
                  }
                ]
              }
            }
          }
        }
      },
      "SupplierInvoice": {
        "type": "object",
        "title": "SupplierInvoice",
        "description": "Factura recibida de un proveedor (accounts payable).",
        "required": [
          "id",
          "organization_id",
          "supplier_name",
          "supplier_tax_id",
          "supplier_id",
          "number",
          "issue_date",
          "due_date",
          "project_id",
          "subtotal",
          "tax",
          "total",
          "status",
          "notes",
          "cost_center_id",
          "is_intra_eu",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "supplier_name": {
            "type": "string",
            "description": "Nombre del proveedor.",
            "examples": [
              "Acme Proveedor SL"
            ]
          },
          "supplier_tax_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "NIF/CIF del proveedor; null si no se ha indicado.",
            "examples": [
              "B12345678"
            ]
          },
          "supplier_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del proveedor (entidad Supplier) al que pertenece esta factura; `null` si la factura aún no está vinculada a un proveedor."
          },
          "number": {
            "type": "string",
            "description": "Número de factura del proveedor.",
            "examples": [
              "FACT-2026-001"
            ]
          },
          "issue_date": {
            "type": "string",
            "format": "date",
            "description": "Fecha de emisión de la factura del proveedor.",
            "examples": [
              "2026-01-15"
            ]
          },
          "due_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha de vencimiento; null si no se ha indicado.",
            "examples": [
              "2026-02-15"
            ]
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Proyecto al que se imputa esta factura; null si no aplica."
          },
          "subtotal": {
            "type": "number",
            "description": "Base imponible (EUR).",
            "examples": [
              1000
            ]
          },
          "tax": {
            "type": "number",
            "description": "Cuota de IVA soportado (EUR).",
            "examples": [
              210
            ]
          },
          "total": {
            "type": "number",
            "description": "Total (base + IVA).",
            "examples": [
              1210
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "received",
              "approved",
              "rejected",
              "paid",
              "void"
            ],
            "description": "Estado de la factura de proveedor."
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "description": "Notas libres."
          },
          "cost_center_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Centro de coste al que se imputa. El API lo devolvía desde el principio y el contrato no lo declaraba (PJKT-2026), así que el SDK no lo conocía y la pantalla no podía ni leerlo ni rellenarlo al editar."
          },
          "is_intra_eu": {
            "type": "boolean",
            "description": "Operación intracomunitaria (modelo 349). Mismo caso que `cost_center_id`: se servía sin estar declarado."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-01-15T09:00:00Z"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-01-15T09:30:00Z"
            ]
          }
        }
      },
      "SupplierInvoiceCreate": {
        "type": "object",
        "title": "SupplierInvoiceCreate",
        "description": "Datos para crear una factura recibida de proveedor.",
        "required": [
          "supplier_name",
          "number",
          "issue_date",
          "subtotal",
          "total"
        ],
        "properties": {
          "supplier_name": {
            "type": "string",
            "description": "Nombre del proveedor.",
            "examples": [
              "Acme Proveedor SL"
            ]
          },
          "supplier_tax_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "NIF/CIF del proveedor (opcional).",
            "examples": [
              "B12345678"
            ]
          },
          "supplier_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del proveedor (Supplier entity) al que pertenece esta factura (opcional)."
          },
          "number": {
            "type": "string",
            "description": "Número de factura tal como aparece en el documento del proveedor.",
            "examples": [
              "FACT-2026-001"
            ]
          },
          "issue_date": {
            "type": "string",
            "format": "date",
            "description": "Fecha de emisión.",
            "examples": [
              "2026-01-15"
            ]
          },
          "due_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha de vencimiento (opcional).",
            "examples": [
              "2026-02-15"
            ]
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Proyecto al que imputar la factura (opcional)."
          },
          "subtotal": {
            "type": "number",
            "description": "Base imponible (EUR).",
            "examples": [
              1000
            ]
          },
          "tax": {
            "type": "number",
            "description": "Cuota de IVA soportado (EUR). Por defecto 0.",
            "default": 0,
            "examples": [
              210
            ]
          },
          "total": {
            "type": "number",
            "description": "Total (base + IVA).",
            "examples": [
              1210
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "description": "Notas libres (opcional)."
          },
          "cost_center_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Centro de coste al que se imputa (opcional). El API lo aceptaba desde el principio y el contrato no lo declaraba (PJKT-2029): la pantalla lo mandaba igualmente porque el tipo del frontend estaba escrito a mano en vez de derivarse de aquí. Funcionaba, pero nadie que leyera el contrato lo sabría."
          },
          "is_intra_eu": {
            "type": "boolean",
            "description": "Operación intracomunitaria (modelo 349). Opcional; el API asume `false`. Sin `default` a propósito: el generador de tipos trata un campo con `default` como siempre presente, y en un cuerpo de petición eso lo convertiría en obligatorio."
          }
        }
      },
      "ApAgingReport": {
        "type": "object",
        "title": "ApAgingReport",
        "description": "Aging de cuentas a pagar: facturas de proveedor pendientes de pago (received, approved) troceadas por antigüedad, con los principales proveedores.",
        "required": [
          "buckets",
          "top_suppliers",
          "total_outstanding",
          "criterio"
        ],
        "properties": {
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 de todos los importes del informe — la base de la organización. Solo se agregan facturas recibidas en ella; lo recibido en otra divisa queda fuera. No se convierte nada.",
            "examples": [
              "USD"
            ]
          },
          "buckets": {
            "type": "array",
            "description": "Tramos de antigüedad; siempre los cinco, con 0 si están vacíos. Su suma es `total_outstanding`.",
            "items": {
              "type": "object",
              "required": [
                "label",
                "count",
                "total"
              ],
              "properties": {
                "label": {
                  "type": "string",
                  "description": "Días de retraso sobre la fecha de vencimiento, salvo `sin-vencimiento`: las facturas recibidas que no traen `due_date`. Son deuda igual —cuentan en el total y en su proveedor— pero no tienen retraso que medir, así que no se reparten entre los otros tramos.",
                  "enum": [
                    "0-30",
                    "31-60",
                    "61-90",
                    "90+",
                    "sin-vencimiento"
                  ]
                },
                "count": {
                  "type": "integer",
                  "examples": [
                    2
                  ]
                },
                "total": {
                  "type": "number",
                  "examples": [
                    3500
                  ]
                }
              }
            }
          },
          "top_suppliers": {
            "type": "array",
            "description": "Hasta 5 proveedores con más deuda pendiente, de mayor a menor.",
            "items": {
              "type": "object",
              "required": [
                "supplier_name",
                "outstanding",
                "count"
              ],
              "properties": {
                "supplier_name": {
                  "type": "string",
                  "examples": [
                    "Acme Proveedor SL"
                  ]
                },
                "outstanding": {
                  "type": "number",
                  "description": "Importe pendiente de pago al proveedor.",
                  "examples": [
                    1210
                  ]
                },
                "count": {
                  "type": "integer",
                  "description": "Número de facturas pendientes del proveedor.",
                  "examples": [
                    1
                  ]
                }
              }
            }
          },
          "total_outstanding": {
            "type": "number",
            "description": "Suma total de deuda pendiente de pago.",
            "examples": [
              4710
            ]
          },
          "criterio": {
            "$ref": "#/components/schemas/CriterioDeCalculo"
          }
        }
      },
      "Modelo303": {
        "type": "object",
        "title": "Modelo303",
        "description": "Resumen fiscal IVA (Modelo 303 simplificado) para el periodo indicado. repercutido = IVA de facturas emitidas; soportado = IVA de facturas recibidas (AP) + gastos; resultado = repercutido − soportado.",
        "required": [
          "period",
          "iva_repercutido",
          "iva_soportado_ap",
          "iva_soportado_expenses",
          "iva_soportado",
          "resultado"
        ],
        "properties": {
          "period": {
            "type": "string",
            "description": "Periodo consultado (YYYY, YYYY-Qn, o YYYY-MM).",
            "examples": [
              "2026",
              "2026-Q1",
              "2026-07"
            ]
          },
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 de las cuatro cuotas y del resultado — la base de la organización. Las tres sumas cuentan SOLO documentos emitidos en ella: antes sumaban el `tax_amount` de todas las divisas en un mismo número y lo presentaban como la casilla de una declaración. Lo emitido en otra divisa queda fuera; no se convierte nada.",
            "examples": [
              "EUR"
            ]
          },
          "iva_repercutido": {
            "type": "number",
            "description": "IVA de facturas emitidas no canceladas (cuota iva repercutido).",
            "examples": [
              210
            ]
          },
          "iva_soportado_ap": {
            "type": "number",
            "description": "IVA de facturas de proveedor (cuota iva soportado cuentas a pagar).",
            "examples": [
              105
            ]
          },
          "iva_soportado_expenses": {
            "type": "number",
            "description": "Cuota desglosada (`tax_amount`) de los gastos NORMALES aprobados del periodo. No incluye la de los gastos cuya factura de proveedor ya entra en `iva_soportado_ap` de esta misma declaración: sería la misma factura física deducida dos veces.",
            "examples": [
              0
            ]
          },
          "iva_soportado": {
            "type": "number",
            "description": "IVA soportado total (AP + gastos).",
            "examples": [
              105
            ]
          },
          "resultado": {
            "type": "number",
            "description": "Resultado del periodo: positivo = a ingresar a Hacienda; negativo = a compensar/devolver.",
            "examples": [
              105
            ]
          },
          "iva_soportado_expenses_descartado": {
            "type": "number",
            "description": "Cuota de gastos normales que NO está en `iva_soportado_expenses` porque su factura de proveedor ya entra en `iva_soportado_ap` de esta misma declaración. El importe sigue contado una vez, por el lado que soporta la deducción (la factura recibida, con el NIF del proveedor). Se publica para que la casilla sea auditable contra trimestres anteriores en vez de encogerse sin explicación.",
            "examples": [
              0
            ]
          },
          "duplicados_posibles": {
            "type": "array",
            "description": "Gastos normales que anotan el número de una factura de proveedor que NO entra en esta declaración (otro periodo, otra divisa, o sin IVA desglosado). Su cuota SÍ se cuenta aquí —descartarla la dejaría fuera de todas las declaraciones, porque la mitad AP tampoco la aporta—, así que el informe avisa en vez de decidir por su cuenta: perder IVA deducible en silencio es tan grave como duplicarlo.",
            "items": {
              "type": "object",
              "title": "Modelo303Duplicado",
              "required": [
                "supplier_invoice_number",
                "motivo",
                "cuota"
              ],
              "properties": {
                "supplier_invoice_number": {
                  "type": "string",
                  "description": "Número de factura de proveedor anotado en el gasto.",
                  "examples": [
                    "F-2026-0042"
                  ]
                },
                "motivo": {
                  "type": "string",
                  "enum": [
                    "fuera_de_periodo",
                    "otra_divisa",
                    "sin_cuota_en_factura"
                  ],
                  "description": "Por qué la factura de proveedor no entra en esta declaración: `fuera_de_periodo` = está en otro periodo; `otra_divisa` = está en una divisa distinta de la declarada (no se convierte nada); `sin_cuota_en_factura` = está en el periodo y en la divisa pero sin IVA desglosado, así que aporta 0."
                },
                "cuota": {
                  "type": "number",
                  "description": "Cuota del gasto que sí se está sumando en esta declaración.",
                  "examples": [
                    210
                  ]
                }
              }
            }
          }
        }
      },
      "Modelo347": {
        "type": "object",
        "title": "Modelo347",
        "description": "Informe simplificado Modelo 347: terceros con operaciones anuales superiores a 3.005,06 € (umbral legal). Incluye operaciones AR (facturas emitidas) y, del lado recibido, TANTO las facturas de proveedor como los gastos normales aprobados: el umbral es por tercero, no por pantalla del producto, y lo pagado a un proveedor sin darlo de alta como factura recibida también cuenta. Las dos mitades se suman por tercero ANTES de comparar contra el umbral, y un gasto que anota una factura de proveedor del mismo ejercicio y divisa no vuelve a sumar.",
        "required": [
          "year",
          "parties"
        ],
        "properties": {
          "year": {
            "type": "integer",
            "description": "Ejercicio fiscal.",
            "examples": [
              2026
            ]
          },
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 de los importes Y del umbral con el que se decide quién entra en la lista — la base de la organización. El umbral son 3.005,06 EUROS de la ley española: comparado contra una suma que mezclaba divisas, metía terceros que no llegan y dejaba fuera a otros que sí. Solo se agregan documentos emitidos en esta divisa; no se convierte nada.",
            "examples": [
              "EUR"
            ]
          },
          "parties": {
            "type": "array",
            "items": {
              "type": "object",
              "title": "Modelo347Party",
              "required": [
                "party_name",
                "tax_id",
                "direction",
                "total"
              ],
              "properties": {
                "party_name": {
                  "type": "string",
                  "description": "Nombre del tercero.",
                  "examples": [
                    "Acme Corp SL"
                  ]
                },
                "tax_id": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "NIF/CIF del tercero; null si no disponible.",
                  "examples": [
                    "B12345678"
                  ]
                },
                "direction": {
                  "type": "string",
                  "enum": [
                    "emitidas",
                    "recibidas"
                  ],
                  "description": "Lado de la operación, con los MISMOS literales que sirve el API: `emitidas` = factura emitida a un cliente (AR en vocabulario contable); `recibidas` = lo pagado a un tercero, ya sea factura de proveedor o gasto normal aprobado (AP). Hasta 2026-08 este enum declaraba `AR`/`AP`, valores que el API nunca ha devuelto: el SDK tipaba un enum imposible y el consumidor que ramificaba sobre él escribía ramas muertas (el panel de finanzas etiquetaba TODAS las filas como AP). Se corrigió el contrato, no el API: los literales en castellano son los que viaja `/api/v1` desde el día uno y cambiarlos sería un breaking de v1 (regla 4)."
                },
                "total": {
                  "type": "number",
                  "description": "Total anual de operaciones con este tercero (EUR).",
                  "examples": [
                    5000
                  ]
                }
              }
            }
          }
        }
      },
      "Modelo349": {
        "type": "object",
        "title": "Modelo349",
        "description": "Informe simplificado Modelo 349: operaciones intracomunitarias marcadas explícitamente con `is_intra_eu = true` en facturas emitidas y recibidas. La bandera es explícita (no derivada del prefijo del NIF-IVA) para garantizar cobertura simétrica entre AR y AP.",
        "required": [
          "year",
          "parties"
        ],
        "properties": {
          "year": {
            "type": "integer",
            "description": "Ejercicio fiscal.",
            "examples": [
              2026
            ]
          },
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 de los importes — la base de la organización. Solo se agregan documentos emitidos en ella; no se convierte nada.",
            "examples": [
              "EUR"
            ]
          },
          "parties": {
            "type": "array",
            "items": {
              "type": "object",
              "title": "Modelo349Party",
              "required": [
                "party_name",
                "tax_id",
                "direction",
                "total"
              ],
              "properties": {
                "party_name": {
                  "type": "string",
                  "description": "Nombre del tercero.",
                  "examples": [
                    "EU Partner GmbH"
                  ]
                },
                "tax_id": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "NIF-IVA intracomunitario del tercero.",
                  "examples": [
                    "DE123456789"
                  ]
                },
                "direction": {
                  "type": "string",
                  "enum": [
                    "emitidas",
                    "recibidas"
                  ],
                  "description": "Lado de la operación, con los MISMOS literales que sirve el API: `emitidas` = entrega o prestación intracomunitaria (factura emitida, AR en vocabulario contable); `recibidas` = adquisición intracomunitaria (factura de proveedor, AP). Hasta 2026-08 este enum declaraba `AR`/`AP`, valores que el API nunca ha devuelto — ver la nota en `modelo_347.yaml`."
                },
                "total": {
                  "type": "number",
                  "description": "Base imponible total de operaciones intracomunitarias (EUR).",
                  "examples": [
                    8500
                  ]
                }
              }
            }
          }
        }
      },
      "Budget": {
        "type": "object",
        "title": "Budget",
        "description": "Entrada de presupuesto para una categoría / período / proyecto.",
        "required": [
          "id",
          "organization_id",
          "category",
          "period",
          "amount",
          "project_id",
          "notes",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "category": {
            "type": "string",
            "description": "Categoría de gasto presupuestada.",
            "examples": [
              "marketing"
            ]
          },
          "period": {
            "type": "string",
            "description": "Período: YYYY, YYYY-Qn o YYYY-MM.",
            "examples": [
              "2026",
              "2026-Q1",
              "2026-07"
            ]
          },
          "amount": {
            "type": "number",
            "description": "Importe presupuestado (EUR).",
            "examples": [
              5000
            ]
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Proyecto asociado; null si aplica a toda la organización."
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "description": "Notas libres."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "BudgetVsActual": {
        "type": "object",
        "title": "BudgetVsActual",
        "description": "Comparativa de presupuesto vs. gasto real para un período dado. Cada línea representa un Budget de la organización con sus totales reales calculados sumando gastos + facturas de proveedor del mismo período/categoría.",
        "required": [
          "period",
          "lines",
          "total_budgeted",
          "total_actual",
          "total_remaining",
          "criterio"
        ],
        "properties": {
          "period": {
            "type": "string",
            "description": "Período consultado: YYYY, YYYY-Qn o YYYY-MM.",
            "examples": [
              "2026",
              "2026-Q1"
            ]
          },
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 de TODOS los importes del reporte — la base de la organización. Un presupuesto no tiene divisa propia (es dinero de la casa), así que el gasto real con el que se compara se acota a esta: sumar euros y dólares daba un «excedido» que no había ocurrido. No se convierte nada; lo gastado en otra divisa queda fuera.",
            "examples": [
              "USD"
            ]
          },
          "lines": {
            "type": "array",
            "items": {
              "type": "object",
              "title": "BudgetVsActualLine",
              "required": [
                "budget_id",
                "category",
                "project_id",
                "period",
                "budgeted",
                "actual",
                "remaining",
                "pct_used"
              ],
              "properties": {
                "budget_id": {
                  "type": "string",
                  "format": "uuid"
                },
                "category": {
                  "type": "string",
                  "examples": [
                    "marketing"
                  ]
                },
                "project_id": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "format": "uuid"
                },
                "period": {
                  "type": "string"
                },
                "budgeted": {
                  "type": "number",
                  "description": "Importe presupuestado, en la `currency` del reporte."
                },
                "actual": {
                  "type": "number",
                  "description": "Gasto real en el período, en la `currency` del reporte."
                },
                "remaining": {
                  "type": "number",
                  "description": "Presupuesto restante (budgeted − actual)."
                },
                "pct_used": {
                  "type": "number",
                  "description": "Porcentaje utilizado (0–100+). 0 si presupuestado es 0."
                }
              }
            }
          },
          "total_budgeted": {
            "type": "number",
            "description": "Suma de todos los importes presupuestados."
          },
          "total_actual": {
            "type": "number",
            "description": "Suma de todos los gastos reales."
          },
          "total_remaining": {
            "type": "number",
            "description": "Suma de todos los importes restantes."
          },
          "criterio": {
            "$ref": "#/components/schemas/CriterioDeCalculo",
            "description": "De dónde sale `total_actual`: sus fuentes (el gasto normal por categoría y las facturas de proveedor del período), su divisa, sus cotas y qué documentos quedaron fuera y por qué.\n\nVa referido al TOTAL del informe y no a una línea, porque el informe tiene un total que las líneas no reproducen: las facturas de proveedor no tienen categoría y por eso se cuentan una sola vez a nivel de reporte en vez de atribuirse a cada línea. `Σ lines[].actual ≠ total_actual` es deliberado y la diferencia es exactamente esa mitad.\n\nEl motivo que hacía falta para poder publicar esto es `sin_linea_de_presupuesto`: un gasto de una categoría que nadie presupuestó está en la divisa, en el período y en un estado que cuenta, y no aparece ni en una línea ni en `total_actual`. Sin ese motivo, publicar aquí un criterio habría afirmado una partición exhaustiva falsa — y la exhaustividad es lo único que hace auditable a este objeto."
          }
        }
      },
      "CostCenter": {
        "type": "object",
        "title": "CostCenter",
        "description": "Centro de coste con soporte de árbol jerárquico (parent_id).",
        "required": [
          "id",
          "organization_id",
          "code",
          "name",
          "parent_id",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "code": {
            "type": "string",
            "description": "Código único dentro de la organización (máx. 20 caracteres).",
            "examples": [
              "MKT-001"
            ]
          },
          "name": {
            "type": "string",
            "description": "Nombre descriptivo del centro de coste.",
            "examples": [
              "Marketing Digital"
            ]
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "ID del centro de coste padre; null si es un nodo raíz."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "CostCenterRollup": {
        "type": "object",
        "title": "CostCenterRollup",
        "description": "Totales de gasto por centro de coste para el período indicado. Suma gastos (expenses) + facturas de proveedor + facturas emitidas imputadas a cada centro.",
        "required": [
          "period",
          "lines",
          "criterio"
        ],
        "properties": {
          "period": {
            "type": "string",
            "description": "Período consultado: YYYY, YYYY-Qn o YYYY-MM.",
            "examples": [
              "2026-Q2"
            ]
          },
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 de todos los importes del rollup — la base de la organización. Los tres agregados suman SOLO documentos emitidos en ella: antes eran `SUM(...)` ciegos y el `grand_total` mezclaba monedas en una cifra que se imputaba a un departamento. No se convierte nada; lo emitido en otra divisa queda fuera.",
            "examples": [
              "USD"
            ]
          },
          "lines": {
            "type": "array",
            "items": {
              "type": "object",
              "title": "CostCenterRollupLine",
              "required": [
                "cost_center_id",
                "code",
                "name",
                "expenses_total",
                "supplier_invoices_total",
                "invoices_total",
                "grand_total"
              ],
              "properties": {
                "cost_center_id": {
                  "type": "string",
                  "format": "uuid"
                },
                "code": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "expenses_total": {
                  "type": "number",
                  "description": "Suma de gastos imputados a este CC en el período."
                },
                "supplier_invoices_total": {
                  "type": "number",
                  "description": "Suma de facturas de proveedor imputadas a este CC."
                },
                "invoices_total": {
                  "type": "number",
                  "description": "Suma de facturas emitidas imputadas a este CC."
                },
                "grand_total": {
                  "type": "number",
                  "description": "Total combinado (expenses + AP + AR)."
                }
              }
            }
          },
          "criterio": {
            "$ref": "#/components/schemas/CriterioDeCalculo",
            "description": "De dónde salen los importes de las filas: las tres familias que el rollup imputa (gasto normal, factura de proveedor y factura emitida), cada una con su columna de fecha y sus estados, más lo que quedó fuera y por qué.\n\nVa una vez para el informe entero y no por fila, porque el descarte que importa aquí no pertenece a ninguna: un documento SIN centro de coste no está en ninguna fila, así que no hay fila donde contarlo. Ese es el motivo `sin_centro_de_coste`, y es lo que hacía falta para poder publicar este objeto sin afirmar una partición exhaustiva falsa."
          }
        }
      },
      "FixedAsset": {
        "type": "object",
        "title": "FixedAsset",
        "description": "Activo registrado para amortización contable (asset_type='fixed') o con cotización en mercado (asset_type='crypto'|'etf'). Los activos de mercado usan symbol/quantity/market_value; los fijos se amortizan linealmente.",
        "required": [
          "id",
          "organization_id",
          "name",
          "category",
          "asset_type",
          "acquisition_date",
          "acquisition_cost",
          "useful_life_months",
          "salvage_value",
          "method",
          "disposed_date",
          "crypto_subtype",
          "symbol",
          "quantity",
          "market_value",
          "market_price",
          "last_price_at",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "description": "Nombre del activo.",
            "examples": [
              "Servidor Dell PowerEdge R750"
            ]
          },
          "category": {
            "type": "string",
            "description": "Categoría contable del activo.",
            "examples": [
              "hardware",
              "mobiliario",
              "vehiculo"
            ]
          },
          "asset_type": {
            "type": "string",
            "enum": [
              "fixed",
              "crypto",
              "etf"
            ],
            "description": "Tipo de activo: 'fixed' (inmovilizado amortizable), 'crypto' (criptomoneda) o 'etf' (ETF/acción). Los dos últimos cotizan en mercado."
          },
          "acquisition_date": {
            "type": "string",
            "format": "date",
            "description": "Fecha de adquisición (inicio de la amortización).",
            "examples": [
              "2024-01-01"
            ]
          },
          "acquisition_cost": {
            "type": "number",
            "description": "Coste de adquisición (EUR).",
            "examples": [
              12000
            ]
          },
          "useful_life_months": {
            "type": "integer",
            "description": "Vida útil en meses (para amortización lineal).",
            "examples": [
              60
            ]
          },
          "salvage_value": {
            "type": "number",
            "description": "Valor residual al final de la vida útil (EUR).",
            "examples": [
              0
            ]
          },
          "method": {
            "type": "string",
            "enum": [
              "straight_line"
            ],
            "description": "Método de amortización. Actualmente solo línea recta."
          },
          "disposed_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha de baja del activo; null si sigue activo."
          },
          "crypto_subtype": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "native",
              "token",
              "stablecoin",
              null
            ],
            "description": "Subtipo de cripto (solo asset_type='crypto'): 'native' (moneda nativa), 'token' o 'stablecoin'. null en otros tipos."
          },
          "symbol": {
            "type": [
              "string",
              "null"
            ],
            "description": "Ticker de mercado (BTC, ETH, VWCE.DE...). null para activos fijos.",
            "examples": [
              "BTC",
              "VWCE.DE"
            ]
          },
          "quantity": {
            "type": [
              "number",
              "null"
            ],
            "description": "Unidades poseídas (para valorar a mercado). null para activos fijos.",
            "examples": [
              0.5
            ]
          },
          "market_value": {
            "type": [
              "number",
              "null"
            ],
            "description": "Último valor de mercado calculado (quantity * market_price, EUR). null si aún no se ha refrescado o el proveedor no dio precio."
          },
          "market_price": {
            "type": [
              "number",
              "null"
            ],
            "description": "Último precio unitario observado (EUR). null si no disponible."
          },
          "last_price_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Instante de la última actualización de precio. null si nunca."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "AssetAmortization": {
        "type": "object",
        "title": "AssetAmortization",
        "description": "Cuadro de amortización mensual (línea recta) de un activo fijo. La cuota mensual es (coste − valor_residual) / vida_útil, redondeada a 2 d.p. El último mes absorbe la diferencia de redondeo para que el total sea exacto.",
        "required": [
          "asset_id",
          "name",
          "acquisition_cost",
          "salvage_value",
          "useful_life_months",
          "monthly_depreciation",
          "accumulated_depreciation",
          "book_value",
          "as_of",
          "schedule"
        ],
        "properties": {
          "asset_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "acquisition_cost": {
            "type": "number"
          },
          "salvage_value": {
            "type": "number"
          },
          "useful_life_months": {
            "type": "integer"
          },
          "monthly_depreciation": {
            "type": "number",
            "description": "Cuota mensual de amortización estándar."
          },
          "accumulated_depreciation": {
            "type": "number",
            "description": "Amortización acumulada a la fecha `as_of`."
          },
          "book_value": {
            "type": "number",
            "description": "Valor neto contable a la fecha `as_of`."
          },
          "as_of": {
            "type": "string",
            "format": "date",
            "description": "Fecha de referencia para accumulated_depreciation y book_value."
          },
          "schedule": {
            "type": "array",
            "description": "Tabla completa mes a mes.",
            "items": {
              "type": "object",
              "title": "AmortizationMonth",
              "required": [
                "month",
                "depreciation",
                "accumulated",
                "book_value"
              ],
              "properties": {
                "month": {
                  "type": "string",
                  "description": "Mes en formato YYYY-MM.",
                  "examples": [
                    "2024-01"
                  ]
                },
                "depreciation": {
                  "type": "number",
                  "description": "Cuota de amortización del mes."
                },
                "accumulated": {
                  "type": "number",
                  "description": "Amortización acumulada al final del mes."
                },
                "book_value": {
                  "type": "number",
                  "description": "Valor neto contable al final del mes."
                }
              }
            }
          }
        }
      },
      "AssetsRegisterSummary": {
        "type": "object",
        "title": "AssetsRegisterSummary",
        "description": "Resumen agregado del registro de activos fijos: totales de coste, amortización acumulada y valor neto contable a una fecha de referencia.",
        "required": [
          "total_assets",
          "active_assets",
          "disposed_assets",
          "total_acquisition_cost",
          "total_accumulated_depreciation",
          "total_net_book_value",
          "total_market_value"
        ],
        "properties": {
          "total_assets": {
            "type": "integer",
            "description": "Número total de activos (activos + dados de baja)."
          },
          "active_assets": {
            "type": "integer",
            "description": "Activos no dados de baja."
          },
          "disposed_assets": {
            "type": "integer",
            "description": "Activos dados de baja (disposed_date IS NOT NULL)."
          },
          "total_acquisition_cost": {
            "type": "number",
            "description": "Suma de coste de adquisición de todos los activos (EUR)."
          },
          "total_accumulated_depreciation": {
            "type": "number",
            "description": "Suma de amortización acumulada a la fecha de referencia (EUR)."
          },
          "total_net_book_value": {
            "type": "number",
            "description": "Valor neto contable total a la fecha de referencia (EUR). Para activos crypto/etf usa el valor de mercado si se conoce, si no el coste."
          },
          "total_market_value": {
            "type": "number",
            "description": "Valor de mercado agregado de los activos crypto/etf con precio conocido (EUR)."
          }
        }
      },
      "AssetPriceRefresh": {
        "type": "object",
        "title": "AssetPriceRefresh",
        "description": "Resumen de una actualización de precios en lote. Cada activo crypto/etf se consulta contra su proveedor (CoinGecko para crypto, Stooq para etf) y, si hay precio, se recalcula market_value = quantity * precio. Degrada por activo: si el proveedor no da precio, se conserva el valor anterior (status 'unavailable').",
        "required": [
          "updated",
          "unavailable",
          "skipped",
          "items"
        ],
        "properties": {
          "updated": {
            "type": "integer",
            "description": "Número de activos cuyo precio se actualizó."
          },
          "unavailable": {
            "type": "integer",
            "description": "Activos cuyo proveedor no devolvió precio (valor conservado)."
          },
          "skipped": {
            "type": "integer",
            "description": "Activos omitidos (no son crypto/etf o no tienen symbol)."
          },
          "items": {
            "type": "array",
            "description": "Detalle por activo procesado.",
            "items": {
              "type": "object",
              "title": "AssetPriceRefreshItem",
              "required": [
                "asset_id",
                "symbol",
                "asset_type",
                "status",
                "market_price",
                "market_value",
                "last_price_at"
              ],
              "properties": {
                "asset_id": {
                  "type": "string",
                  "format": "uuid"
                },
                "symbol": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "Ticker del activo."
                },
                "asset_type": {
                  "type": "string",
                  "enum": [
                    "fixed",
                    "crypto",
                    "etf"
                  ]
                },
                "status": {
                  "type": "string",
                  "enum": [
                    "updated",
                    "unavailable",
                    "skipped"
                  ],
                  "description": "Resultado del refresco para este activo."
                },
                "market_price": {
                  "type": [
                    "number",
                    "null"
                  ],
                  "description": "Precio unitario resultante (EUR); null si no disponible."
                },
                "market_value": {
                  "type": [
                    "number",
                    "null"
                  ],
                  "description": "Valor de mercado resultante (EUR); null si no disponible."
                },
                "last_price_at": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "format": "date-time",
                  "description": "Instante del precio; null si nunca se actualizó."
                }
              }
            }
          }
        }
      },
      "BulkActionResult": {
        "type": "object",
        "title": "BulkActionResult",
        "description": "Resultado por-elemento de una acción en lote. `processed` son los que cambiaron de estado; `skipped` los que ya estaban en el estado destino (idempotencia); `errors` los que no existían/otro tenant o cuya transición no era válida desde su estado actual.",
        "required": [
          "processed",
          "skipped",
          "errors"
        ],
        "properties": {
          "processed": {
            "type": "integer",
            "description": "Número de elementos cuyo estado cambió efectivamente.",
            "examples": [
              2
            ]
          },
          "skipped": {
            "type": "integer",
            "description": "Elementos ya en el estado destino (no-op).",
            "examples": [
              1
            ]
          },
          "errors": {
            "type": "array",
            "description": "Elementos no aplicados, con el motivo por id.",
            "items": {
              "type": "object",
              "required": [
                "id",
                "reason"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid"
                },
                "reason": {
                  "type": "string",
                  "description": "Motivo (`not_found` o descripción de transición inválida).",
                  "examples": [
                    "not_found"
                  ]
                }
              }
            }
          }
        }
      },
      "SupplierInvoiceBulkActionIn": {
        "type": "object",
        "title": "SupplierInvoiceBulkActionIn",
        "description": "Acción en lote sobre facturas de proveedor (AP). Las acciones de estado (approve/reject/mark_paid/void) siguen la máquina de estados por elemento y son idempotentes: se omite el que ya está en el estado destino. `delete` elimina las facturas indicadas y, por ser destructiva, requiere `confirm: true` (sin él la petición es 422). Devuelve un BulkActionResult honesto. Requiere admin+.",
        "required": [
          "ids",
          "action"
        ],
        "properties": {
          "ids": {
            "type": "array",
            "minItems": 1,
            "maxItems": 200,
            "description": "IDs de facturas de proveedor (org-scoped) sobre las que actuar.",
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "examples": [
              [
                "a1b2c3d4-e5f6-7a8b-9c0d-e1f2a3b4c5d6",
                "b2c3d4e5-f6a7-8b9c-0d1e-f2a3b4c5d6e7"
              ]
            ]
          },
          "action": {
            "type": "string",
            "description": "Acción a aplicar a cada factura: `approve` (received→approved), `reject` (received/approved→rejected), `mark_paid` (received/approved→paid), `void` (received/approved/rejected→void), `delete` (elimina; requiere confirm).",
            "enum": [
              "approve",
              "reject",
              "mark_paid",
              "void",
              "delete"
            ]
          },
          "confirm": {
            "type": "boolean",
            "default": false,
            "description": "Obligatorio `true` para acciones destructivas (`delete`); en el resto de acciones se ignora. Un `delete` sin `confirm: true` responde 422."
          }
        }
      },
      "InvoiceRenumberResult": {
        "type": "object",
        "title": "InvoiceRenumberResult",
        "required": [
          "dry_run",
          "total",
          "changed",
          "mapping"
        ],
        "properties": {
          "dry_run": {
            "type": "boolean",
            "description": "`true` = plan sin aplicar; `false` = cambios ya escritos."
          },
          "total": {
            "type": "integer",
            "description": "Facturas de la serie evaluadas."
          },
          "changed": {
            "type": "integer",
            "description": "Facturas cuyo número cambia (o cambió)."
          },
          "mapping": {
            "type": "array",
            "description": "Mapa completo viejo→nuevo en orden cronológico.",
            "items": {
              "type": "object",
              "title": "InvoiceRenumberChange",
              "required": [
                "id",
                "old",
                "new"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid"
                },
                "old": {
                  "type": "string"
                },
                "new": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "InvoiceRefundIn": {
        "type": "object",
        "title": "InvoiceRefundIn",
        "description": "Reembolso —total o parcial— de una factura en estado `paid`. El servidor emite una factura RECTIFICATIVA (serie `REC-`) con los importes en negativo y la devuelve.",
        "properties": {
          "amount": {
            "type": [
              "number",
              "null"
            ],
            "exclusiveMinimum": 0,
            "maximum": 9999999999.99,
            "multipleOf": 0.01,
            "description": "Importe BRUTO a devolver (IVA incluido), en la divisa de la factura. Mayor que 0, con COMO MUCHO dos decimales y como mucho doce dígitos; y no puede superar lo que queda por reembolsar (`refundable_amount`). Cualquiera de las cuatro condiciones que falle, `422`. Omitido o `null` reembolsa todo lo que queda.\n\nLos tres decimales se RECHAZAN en vez de redondearse: 0,001 redondeado a 0,00 emitiría una rectificativa de 0,00 € que consume número de la serie fiscal, y nadie debería descubrir por un redondeo que le han devuelto un céntimo distinto del que pidió.",
            "examples": [
              121
            ]
          },
          "refund_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha de la rectificativa (su `issue_date`, la que decide en qué trimestre entra). Por defecto, hoy. NO se deduce de la fecha de cobro porque el producto no la guarda.\n\nAcotada por los dos lados, y las dos dan `422`: no puede ser ANTERIOR a la `issue_date` de la factura rectificada (sería un documento imposible, y metería IVA repercutido negativo en un trimestre que quizá ya se presentó) ni posterior a HOY (una declaración que nadie va a presentar todavía).",
            "examples": [
              "2026-08-19"
            ]
          },
          "reason": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255,
            "description": "Motivo del reembolso. Se guarda en la rectificativa (`rectification_reason` y `notes`) porque forma parte del documento, y además queda en la traza de auditoría.",
            "examples": [
              "Servicio cancelado por el cliente"
            ]
          }
        }
      },
      "InvoiceRefunds": {
        "type": "object",
        "title": "InvoiceRefunds",
        "description": "Reembolsos emitidos sobre una factura, con el importe ya devuelto y el que queda.",
        "required": [
          "invoice_id",
          "invoice_number",
          "currency",
          "total",
          "refunded_amount",
          "refundable_amount",
          "refunds"
        ],
        "properties": {
          "invoice_id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID de la factura ORIGINAL.",
            "examples": [
              "5d4c3b2a-1098-4765-bade-f01234567890"
            ]
          },
          "invoice_number": {
            "type": "string",
            "examples": [
              "INV-00042"
            ]
          },
          "currency": {
            "type": "string",
            "description": "Código ISO 4217 de la factura original; las rectificativas comparten divisa.",
            "examples": [
              "EUR"
            ]
          },
          "total": {
            "type": "number",
            "description": "Total de la factura original, en positivo.",
            "examples": [
              1210
            ]
          },
          "refunded_amount": {
            "type": "number",
            "description": "Suma de lo ya reembolsado, en POSITIVO. Cuenta las rectificativas con el mismo criterio que el `iva_repercutido` del Modelo 303 (todas menos `cancelled` y `draft`).",
            "examples": [
              605
            ]
          },
          "refundable_amount": {
            "type": "number",
            "description": "`total - refunded_amount`: lo que todavía se puede devolver.",
            "examples": [
              605
            ]
          },
          "refunds": {
            "type": "array",
            "description": "Las rectificativas, de la más antigua a la más reciente. Sus importes van en NEGATIVO: son el documento fiscal, no un resumen de él.",
            "items": {
              "$ref": "#/components/schemas/Invoice"
            }
          }
        }
      },
      "RecurringInvoice": {
        "type": "object",
        "title": "RecurringInvoice",
        "description": "Plantilla de factura recurrente.",
        "required": [
          "id",
          "organization_id",
          "client_name",
          "client_email",
          "tax_rate",
          "currency",
          "notes",
          "items",
          "frequency",
          "day_of_month",
          "next_run_date",
          "last_run_date",
          "is_active",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "7a1f2e3d-4c5b-4a69-8d7e-0f1a2b3c4d5e"
            ]
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          },
          "client_name": {
            "type": "string",
            "examples": [
              "Tipsterland S.L."
            ]
          },
          "client_email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "description": "Email del cliente; `null` si no se ha indicado.",
            "examples": [
              "facturacion@tipsterland.es"
            ]
          },
          "tax_rate": {
            "type": "number",
            "description": "Tipo impositivo aplicado a las facturas generadas (p. ej. 21).",
            "default": 21,
            "examples": [
              21
            ]
          },
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 que HEREDAN las facturas generadas. Se fija al crear la plantilla: omitida en el alta, es la divisa base de la organización. NO hay conversión, así que una plantilla en otra divisa queda fuera de los totales de la casa.",
            "examples": [
              "EUR"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "description": "Notas libres; `null` si no se han definido.",
            "examples": [
              "Pago a 30 días por transferencia."
            ]
          },
          "items": {
            "type": "array",
            "description": "Plantilla de líneas de la factura (al menos una).",
            "items": {
              "type": "object",
              "required": [
                "description",
                "quantity",
                "unit_price"
              ],
              "properties": {
                "description": {
                  "type": "string"
                },
                "quantity": {
                  "type": "number"
                },
                "unit_price": {
                  "type": "number"
                }
              }
            }
          },
          "frequency": {
            "type": "string",
            "description": "Cadencia de generación.",
            "enum": [
              "weekly",
              "biweekly",
              "monthly",
              "quarterly",
              "yearly"
            ]
          },
          "day_of_month": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Día del mes (1-28) para cadencias mensuales; `null` en otro caso.",
            "examples": [
              1
            ]
          },
          "next_run_date": {
            "type": "string",
            "format": "date",
            "description": "Próxima fecha de generación.",
            "examples": [
              "2026-08-01"
            ]
          },
          "last_run_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Última fecha en que se generó una factura; `null` si nunca.",
            "examples": [
              "2026-07-01"
            ]
          },
          "is_active": {
            "type": "boolean",
            "description": "Si está activa (participa en la generación automática/`due`).",
            "examples": [
              true
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-01T09:00:00Z"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-01T09:30:00Z"
            ]
          }
        }
      },
      "RecurringExpense": {
        "type": "object",
        "title": "RecurringExpense",
        "description": "Plantilla de gasto recurrente.",
        "required": [
          "id",
          "organization_id",
          "category",
          "description",
          "amount",
          "currency",
          "vendor",
          "tax_rate",
          "frequency",
          "day_of_month",
          "next_run_date",
          "last_run_date",
          "is_active",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091"
            ]
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          },
          "category": {
            "type": "string",
            "description": "Categoría del gasto que genera la plantilla: las mismas 24 del catálogo que `Expense.category`.",
            "enum": [
              "office",
              "travel",
              "software",
              "marketing",
              "payroll",
              "other",
              "hardware",
              "hosting",
              "telecom",
              "subscriptions",
              "professional_services",
              "taxes",
              "insurance",
              "banking",
              "supplies",
              "training",
              "meals",
              "rent",
              "utilities",
              "shipping",
              "legal",
              "advertising",
              "maintenance",
              "fees"
            ]
          },
          "description": {
            "type": "string",
            "examples": [
              "Suscripción mensual a Figma"
            ]
          },
          "amount": {
            "type": "number",
            "description": "Importe del gasto generado (en la moneda indicada).",
            "examples": [
              15
            ]
          },
          "currency": {
            "type": "string",
            "description": "Código ISO 4217 de la moneda.",
            "default": "EUR",
            "examples": [
              "EUR"
            ]
          },
          "tax_rate": {
            "type": [
              "number",
              "null"
            ],
            "description": "Tipo de IVA que heredan los gastos generados; `null` = sin desglose.",
            "examples": [
              21
            ]
          },
          "vendor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Proveedor o comercio; `null` si no se ha indicado.",
            "examples": [
              "Figma Inc"
            ]
          },
          "frequency": {
            "type": "string",
            "description": "Cadencia de generación.",
            "enum": [
              "weekly",
              "biweekly",
              "monthly",
              "quarterly",
              "yearly"
            ]
          },
          "day_of_month": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Día del mes (1-28) para cadencias mensuales; `null` en otro caso.",
            "examples": [
              1
            ]
          },
          "next_run_date": {
            "type": "string",
            "format": "date",
            "description": "Próxima fecha de generación.",
            "examples": [
              "2026-08-01"
            ]
          },
          "last_run_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Última fecha en que se generó un gasto; `null` si nunca.",
            "examples": [
              "2026-07-01"
            ]
          },
          "is_active": {
            "type": "boolean",
            "description": "Si está activo (participa en la generación automática/`due`).",
            "examples": [
              true
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-01T09:00:00Z"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-01T09:30:00Z"
            ]
          }
        }
      },
      "InvoiceShare": {
        "type": "object",
        "title": "InvoiceShare",
        "description": "Token que concede acceso de solo lectura a una factura sin autenticación.",
        "required": [
          "id",
          "invoice_id",
          "organization_id",
          "created_by",
          "expires_at",
          "revoked_at",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID del registro del token."
          },
          "invoice_id": {
            "type": "string",
            "format": "uuid",
            "description": "Factura a la que da acceso."
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "description": "Organización propietaria."
          },
          "token": {
            "type": [
              "string",
              "null"
            ],
            "description": "Valor RAW del token urlsafe; solo presente en la respuesta de creación (POST). Null en el listado — el servidor nunca lo vuelve a exponer.",
            "examples": [
              "pJkT_RaNdOmUrLsAfE32ChArS"
            ]
          },
          "public_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL pública completa para compartir; solo presente en la respuesta de creación.",
            "examples": [
              "https://app.projekt.3xa.es/i/pJkT_RaNdOmUrLsAfE32ChArS"
            ]
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Usuario que creó el token; `null` si la cuenta se borró después. El API lo devolvía en las dos respuestas y el contrato no lo declaraba (PJKT-2323), así que ningún SDK lo veía."
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Fecha de expiración (UTC); `null` = sin expiración.",
            "examples": [
              "2026-08-01T00:00:00Z"
            ]
          },
          "revoked_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Fecha de revocación (UTC); `null` = activo."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-08T10:00:00Z"
            ]
          }
        }
      },
      "FinanceSettings": {
        "type": "object",
        "title": "FinanceSettings",
        "description": "Preferencias de finanzas de la organización. Una organización que nunca las ha tocado responde los valores por defecto sin que exista fila alguna en la base: por eso `updated_at` puede ser `null`.",
        "required": [
          "organization_id",
          "payment_reminders_enabled",
          "verifactu_enabled",
          "updated_at"
        ],
        "properties": {
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "description": "Organización a la que pertenecen estas preferencias."
          },
          "verifactu_enabled": {
            "type": "boolean",
            "description": "Si esta organización lleva registro de facturación Veri*Factu (RD 1007/2023). Nace APAGADO, y encenderlo hace que cada factura emitida a partir de ese momento escriba un registro encadenado. Las facturas ya emitidas NO se registran hacia atrás: la cadena afirma haberse escrito en el momento de emitir, y construirla a posteriori diría eso mismo siendo mentira."
          },
          "payment_reminders_enabled": {
            "type": "boolean",
            "description": "Con `true`, un barrido diario recuerda por correo al cliente cada factura vencida a los 1, 7 y 15 días del vencimiento, desde la dirección que consta en la propia factura. **Por defecto `false`**: el destinatario es un tercero ajeno a Projekt y ningún despliegue debe escribirle sin que la organización lo encienda.",
            "examples": [
              false
            ]
          },
          "updated_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Última vez que se cambiaron estas preferencias (UTC). `null` = nunca se han tocado — distingue «lo apagaron» de «nunca lo encendieron».",
            "examples": [
              "2026-08-23T10:00:00Z"
            ]
          }
        }
      },
      "FinanceSettingsUpdate": {
        "type": "object",
        "title": "FinanceSettingsUpdate",
        "description": "Cambio de las preferencias de finanzas de la organización.",
        "required": [
          "payment_reminders_enabled"
        ],
        "properties": {
          "verifactu_enabled": {
            "oneOf": [
              {
                "type": "boolean"
              },
              {
                "type": "null"
              }
            ],
            "description": "OPCIONAL: `null` u omitido significa «déjalo como estaba». Obligatorio sería un cambio breaking de `/api/v1`, y por defecto `false` apagaría el libro cada vez que alguien tocara el otro interruptor.\n\nSi esta organización lleva registro de facturación Veri*Factu (RD 1007/2023). Nace APAGADO, y encenderlo hace que cada factura emitida a partir de ese momento escriba un registro encadenado. Las facturas ya emitidas NO se registran hacia atrás: la cadena afirma haberse escrito en el momento de emitir, y construirla a posteriori diría eso mismo siendo mentira."
          },
          "payment_reminders_enabled": {
            "type": "boolean",
            "description": "Enciende o apaga los recordatorios de cobro automáticos hacia los clientes de la organización.",
            "examples": [
              true
            ]
          }
        }
      },
      "InvoiceReminder": {
        "type": "object",
        "title": "InvoiceReminder",
        "description": "Constancia de un recordatorio de cobro enviado. Existe como mucho una fila por cada par (factura, hito), lo que garantiza que un mismo hito no se recuerda dos veces.",
        "required": [
          "id",
          "invoice_id",
          "organization_id",
          "milestone_days",
          "sent_to",
          "sent_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "invoice_id": {
            "type": "string",
            "format": "uuid",
            "description": "Factura recordada."
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "description": "Organización emisora de la factura."
          },
          "milestone_days": {
            "type": "integer",
            "description": "Hito de la cadencia cubierto por este envío: días transcurridos desde el vencimiento (1, 7 o 15). Es el HITO, no los días que llevaba vencida la factura cuando salió el correo — se diferencian si el barrido pasa un día sin correr.",
            "examples": [
              7
            ]
          },
          "sent_to": {
            "type": "string",
            "description": "Dirección a la que se envió, copiada de la factura en el momento del envío. Deliberadamente redundante con `client_email`: esa columna se puede editar después y la pregunta «¿a quién le escribimos?» no admite «a lo que ponga hoy la factura».",
            "examples": [
              "billing@acme.com"
            ]
          },
          "sent_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-08-23T08:00:12Z"
            ]
          }
        }
      },
      "PublicInvoiceItem": {
        "type": "object",
        "title": "PublicInvoiceItem",
        "required": [
          "description",
          "quantity",
          "unit_price",
          "amount"
        ],
        "properties": {
          "description": {
            "type": "string",
            "examples": [
              "Desarrollo web — Sprint 12"
            ]
          },
          "quantity": {
            "type": "number",
            "examples": [
              3
            ]
          },
          "unit_price": {
            "type": "number",
            "examples": [
              250
            ]
          },
          "amount": {
            "type": "number",
            "description": "quantity × unit_price",
            "examples": [
              750
            ]
          }
        }
      },
      "PublicInvoice": {
        "type": "object",
        "title": "PublicInvoice",
        "description": "Representación de solo lectura de una factura accesible mediante un token público. No expone ids internos, notas ni datos de auditoría.",
        "required": [
          "invoice_number",
          "issue_date",
          "due_date",
          "status",
          "currency",
          "subtotal",
          "tax_rate",
          "tax_amount",
          "total",
          "client_name",
          "client_email",
          "org_name",
          "org_logo_url",
          "locale",
          "items"
        ],
        "properties": {
          "invoice_number": {
            "type": "string",
            "examples": [
              "INV-2026-0042"
            ]
          },
          "issue_date": {
            "type": "string",
            "format": "date",
            "examples": [
              "2026-07-01"
            ]
          },
          "due_date": {
            "type": "string",
            "format": "date",
            "examples": [
              "2026-07-31"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "sent",
              "paid",
              "overdue",
              "cancelled"
            ]
          },
          "currency": {
            "type": "string",
            "default": "EUR",
            "examples": [
              "EUR"
            ]
          },
          "subtotal": {
            "type": "number",
            "examples": [
              750
            ]
          },
          "tax_rate": {
            "type": "number",
            "examples": [
              21
            ]
          },
          "tax_amount": {
            "type": "number",
            "examples": [
              157.5
            ]
          },
          "total": {
            "type": "number",
            "examples": [
              907.5
            ]
          },
          "client_name": {
            "type": "string",
            "examples": [
              "Tipsterland S.L."
            ]
          },
          "client_email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "examples": [
              "facturacion@tipsterland.es"
            ]
          },
          "org_name": {
            "type": "string",
            "description": "Nombre de la organización emisora.",
            "examples": [
              "3XA Inc"
            ]
          },
          "org_logo_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL del logo de la organización (puede ser null).",
            "examples": [
              "https://cdn.projekt.3xa.es/logos/3xa.png"
            ]
          },
          "locale": {
            "type": "string",
            "default": "es-ES",
            "description": "Locale BCP-47 de la organización EMISORA, para escribir importes y fechas en su convención. Sin él, quien abre el enlace no tiene con qué decidir si «03/09/2026» es el 3 de septiembre o el 9 de marzo, ni de qué lado del importe va el símbolo de la divisa. Es el locale de la organización, no del usuario: aquí no hay usuario con sesión. Se acompaña de `currency` y nada más — este payload no expone el país ni el resto de la configuración de la organización.",
            "examples": [
              "es-ES",
              "en-US"
            ]
          },
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PublicInvoiceItem"
            }
          }
        }
      },
      "QuoteItem": {
        "type": "object",
        "title": "QuoteItem",
        "description": "Línea de un presupuesto (cantidad, precio unitario e importe calculado).",
        "required": [
          "id",
          "description",
          "quantity",
          "unit_price",
          "amount",
          "position"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "3f2a1b0c-9d8e-4a7b-8c6d-5e4f3a2b1c0d"
            ]
          },
          "description": {
            "type": "string",
            "examples": [
              "Desarrollo backend — sprint 12"
            ]
          },
          "quantity": {
            "type": "number",
            "description": "Unidades presupuestadas.",
            "examples": [
              10
            ]
          },
          "unit_price": {
            "type": "number",
            "description": "Precio por unidad (en la moneda del presupuesto).",
            "examples": [
              75
            ]
          },
          "amount": {
            "type": "number",
            "description": "Importe de la línea (`quantity * unit_price`).",
            "examples": [
              750
            ]
          },
          "position": {
            "type": "integer",
            "description": "Orden de la línea dentro del presupuesto (0 = primera).",
            "examples": [
              0
            ]
          }
        }
      },
      "Quote": {
        "type": "object",
        "title": "Quote",
        "description": "Presupuesto emitido por una organización a un cliente.",
        "required": [
          "id",
          "organization_id",
          "quote_number",
          "client_name",
          "client_email",
          "status",
          "issue_date",
          "expires_at",
          "subtotal",
          "tax_rate",
          "tax_amount",
          "total",
          "currency",
          "notes",
          "items",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "5d4c3b2a-1098-4765-bade-f01234567890"
            ]
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          },
          "quote_number": {
            "type": "string",
            "description": "Identificador legible del presupuesto (secuencial por organización).",
            "examples": [
              "QUO-2026-0042"
            ]
          },
          "client_name": {
            "type": "string",
            "examples": [
              "Tipsterland S.L."
            ]
          },
          "client_email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "description": "Email del cliente; `null` si no se ha indicado.",
            "examples": [
              "facturacion@tipsterland.es"
            ]
          },
          "client_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del cliente CRM vinculado; `null` si el presupuesto no está vinculado a un cliente del directorio.",
            "examples": [
              "3fa85f64-5717-4562-b3fc-2c963f66afa6"
            ]
          },
          "client_address": {
            "type": [
              "string",
              "null"
            ],
            "description": "Dirección fiscal del cliente; `null` si no se ha indicado.",
            "examples": [
              "Calle Ejemplo 1, 28001 Madrid"
            ]
          },
          "client_tax_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "NIF/CIF del cliente; `null` si no se ha indicado.",
            "examples": [
              "B12345678"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "sent",
              "approved",
              "rejected",
              "converted",
              "expired"
            ]
          },
          "issue_date": {
            "type": "string",
            "format": "date",
            "description": "Fecha de emisión.",
            "examples": [
              "2026-07-01"
            ]
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha de caducidad de la oferta; `null` si no caduca.",
            "examples": [
              "2026-07-31"
            ]
          },
          "subtotal": {
            "type": "number",
            "description": "Suma de los importes de línea antes de impuestos.",
            "examples": [
              750
            ]
          },
          "tax_rate": {
            "type": "number",
            "description": "Tipo impositivo aplicado (p. ej. 21 para 21 %).",
            "examples": [
              21
            ]
          },
          "tax_amount": {
            "type": "number",
            "description": "Importe de impuestos (`subtotal * tax_rate / 100`).",
            "examples": [
              157.5
            ]
          },
          "total": {
            "type": "number",
            "description": "Total ofertado (`subtotal + tax_amount`).",
            "examples": [
              907.5
            ]
          },
          "currency": {
            "type": "string",
            "description": "Código ISO 4217 de la moneda.",
            "default": "EUR",
            "examples": [
              "EUR"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "description": "Notas libres; `null` si no se han definido.",
            "examples": [
              "Oferta válida durante 30 días."
            ]
          },
          "deposit_percent": {
            "type": [
              "number",
              "null"
            ],
            "description": "Porcentaje de anticipo (0 < x <= 100) a facturar al convertir; `null` si no se ha definido.",
            "examples": [
              30
            ]
          },
          "converted_invoice_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID de la factura generada al convertir el presupuesto; `null` mientras no se haya convertido.",
            "examples": [
              "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"
            ]
          },
          "approved_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Momento (UTC) en que el cliente aceptó el presupuesto; `null` si no.",
            "examples": [
              "2026-07-05T14:30:00Z"
            ]
          },
          "approved_signer_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nombre de quien firmó la aceptación pública; `null` si no.",
            "examples": [
              "Juan Pérez"
            ]
          },
          "approved_signer_email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "description": "Email de quien firmó la aceptación pública; `null` si no.",
            "examples": [
              "juan@tipsterland.es"
            ]
          },
          "approval_ip": {
            "type": [
              "string",
              "null"
            ],
            "description": "IP desde la que se aceptó el presupuesto; `null` si no.",
            "examples": [
              "203.0.113.42"
            ]
          },
          "signature_hash": {
            "type": [
              "string",
              "null"
            ],
            "description": "Huella SHA-256 de la aceptación (auditoría); `null` si no.",
            "examples": [
              "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
            ]
          },
          "rejected_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Momento (UTC) en que el cliente rechazó el presupuesto; `null` si no.",
            "examples": [
              "2026-07-05T14:30:00Z"
            ]
          },
          "rejected_signer_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nombre de quien rechazó desde el enlace público; `null` si no.",
            "examples": [
              "Juan Pérez"
            ]
          },
          "rejected_signer_email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "description": "Email de quien rechazó desde el enlace público; `null` si no.",
            "examples": [
              "juan@tipsterland.es"
            ]
          },
          "rejection_ip": {
            "type": [
              "string",
              "null"
            ],
            "description": "IP desde la que se rechazó el presupuesto; `null` si no.",
            "examples": [
              "203.0.113.42"
            ]
          },
          "rejection_reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Motivo del rechazo escrito por EL CLIENTE en el enlace público; `null` si no lo indicó.",
            "examples": [
              "El plazo de entrega no nos encaja este trimestre."
            ]
          },
          "internal_rejection_notes": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nota INTERNA del equipo sobre el rechazo. Solo visible dentro del producto (admin+): nunca se expone en el enlace público (`PublicQuote`), ni en el portal de cliente, ni en el PDF que recibe el cliente.",
            "examples": [
              "Perdido por precio frente a la competencia; reintentar en Q4."
            ]
          },
          "items": {
            "type": "array",
            "description": "Líneas del presupuesto.",
            "items": {
              "$ref": "#/components/schemas/QuoteItem"
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-01T09:00:00Z"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-01T09:30:00Z"
            ]
          }
        }
      },
      "QuoteItemIn": {
        "type": "object",
        "title": "QuoteItemIn",
        "description": "Línea de presupuesto enviada al crear o actualizar (sin importe calculado).",
        "required": [
          "description",
          "quantity",
          "unit_price"
        ],
        "properties": {
          "description": {
            "type": "string",
            "examples": [
              "Desarrollo backend — sprint 12"
            ]
          },
          "quantity": {
            "type": "number",
            "description": "Unidades presupuestadas.",
            "examples": [
              10
            ]
          },
          "unit_price": {
            "type": "number",
            "description": "Precio por unidad (en la moneda del presupuesto).",
            "examples": [
              75
            ]
          }
        }
      },
      "QuoteCreateIn": {
        "type": "object",
        "title": "QuoteCreateIn",
        "description": "Datos para crear un presupuesto.",
        "required": [
          "client_name",
          "issue_date",
          "items"
        ],
        "properties": {
          "client_name": {
            "type": "string",
            "examples": [
              "Tipsterland S.L."
            ]
          },
          "client_email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "description": "Opcional; puede omitirse o enviarse `null`."
          },
          "client_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Ficha de cliente (CRM) a vincular. Debe pertenecer a la organización (404 si no). Al vincular, los datos fiscales no enviados (`client_email`, `client_tax_id`, `client_address`) se autorrellenan desde la ficha."
          },
          "client_address": {
            "type": [
              "string",
              "null"
            ],
            "description": "Dirección fiscal del cliente (snapshot en el presupuesto)."
          },
          "client_tax_id": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 20,
            "description": "NIF/CIF del cliente (snapshot en el presupuesto)."
          },
          "issue_date": {
            "type": "string",
            "format": "date",
            "description": "Fecha de emisión."
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha de caducidad de la oferta; `null` si no caduca."
          },
          "items": {
            "type": "array",
            "minItems": 1,
            "description": "Líneas del presupuesto (al menos una).",
            "items": {
              "$ref": "#/components/schemas/QuoteItemIn"
            }
          },
          "tax_rate": {
            "type": "number",
            "default": 0,
            "minimum": 0,
            "maximum": 100,
            "description": "Tipo impositivo (0–100; p. ej. 21). Opcional."
          },
          "currency": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 3,
            "maxLength": 3,
            "description": "Código ISO 4217 (3 letras). Opcional; por defecto EUR."
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "description": "Opcional; puede omitirse o enviarse `null`."
          },
          "deposit_percent": {
            "type": [
              "number",
              "null"
            ],
            "exclusiveMinimum": 0,
            "maximum": 100,
            "description": "Porcentaje de anticipo (0 < x <= 100) a facturar al convertir."
          }
        }
      },
      "QuoteUpdateIn": {
        "type": "object",
        "title": "QuoteUpdateIn",
        "description": "Actualización parcial de un presupuesto; todos los campos son opcionales.",
        "properties": {
          "client_name": {
            "type": "string"
          },
          "client_email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "description": "`null` borra el email del cliente."
          },
          "client_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Vincula una ficha de cliente (CRM) de la organización (404 si no existe). `null` desvincula sin tocar el snapshot."
          },
          "client_address": {
            "type": [
              "string",
              "null"
            ],
            "description": "Dirección fiscal del cliente (snapshot). `null` la borra."
          },
          "client_tax_id": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 20,
            "description": "NIF/CIF del cliente (snapshot). `null` lo borra."
          },
          "issue_date": {
            "type": "string",
            "format": "date",
            "description": "Fecha de emisión. SOLO editable en borrador (422 en otro estado)."
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha de caducidad. SOLO editable en borrador (422 en otro estado); `null` = sin caducidad."
          },
          "items": {
            "type": "array",
            "minItems": 1,
            "description": "Reemplaza TODAS las líneas (SOLO en borrador; 422 en otro estado); el servidor recalcula los totales.",
            "items": {
              "$ref": "#/components/schemas/QuoteItemIn"
            }
          },
          "tax_rate": {
            "type": "number",
            "minimum": 0,
            "maximum": 100,
            "description": "Tipo impositivo (0–100). SOLO editable en borrador (422 en otro estado)."
          },
          "currency": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 3,
            "maxLength": 3,
            "description": "Código ISO 4217 (3 letras)."
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "description": "`null` borra las notas. OJO: estas notas SÍ las ve el cliente."
          },
          "internal_rejection_notes": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 5000,
            "description": "Nota INTERNA del equipo sobre el rechazo (admin+). Editable en cualquier estado —se escribe justo después de que el cliente rechace—. Nunca sale por el enlace público, el portal de cliente ni el PDF. `null` la borra."
          },
          "deposit_percent": {
            "type": [
              "number",
              "null"
            ],
            "exclusiveMinimum": 0,
            "maximum": 100,
            "description": "Porcentaje de anticipo (0 < x <= 100). SOLO editable en borrador (422 en otro estado)."
          }
        }
      },
      "QuoteApproveIn": {
        "type": "object",
        "title": "QuoteApproveIn",
        "description": "Datos de aceptación pública de un presupuesto.",
        "required": [
          "signer_name",
          "signer_email"
        ],
        "properties": {
          "signer_name": {
            "type": "string",
            "description": "Nombre de quien acepta la oferta.",
            "examples": [
              "Juan Pérez"
            ]
          },
          "signer_email": {
            "type": "string",
            "format": "email",
            "description": "Email de quien acepta; OBLIGATORIO desde 2026-08 (antes era opcional). Es la dirección a la que se envía el acuse de recibo de la aceptación, y la única prueba de contacto del firmante que queda en el expediente.",
            "examples": [
              "juan@tipsterland.es"
            ]
          }
        }
      },
      "QuoteRejectIn": {
        "type": "object",
        "title": "QuoteRejectIn",
        "description": "Datos de rechazo público de un presupuesto.",
        "required": [
          "signer_name",
          "signer_email"
        ],
        "properties": {
          "signer_name": {
            "type": "string",
            "description": "Nombre de quien rechaza la oferta.",
            "examples": [
              "Juan Pérez"
            ]
          },
          "signer_email": {
            "type": "string",
            "format": "email",
            "description": "Email de quien rechaza; OBLIGATORIO. Es la dirección a la que se envía el acuse de recibo de la decisión.",
            "examples": [
              "juan@tipsterland.es"
            ]
          },
          "reason": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 2000,
            "description": "Motivo del rechazo escrito por el cliente; opcional. Se muestra al emisor dentro del producto. No es la nota interna del equipo.",
            "examples": [
              "El plazo de entrega no nos encaja este trimestre."
            ]
          }
        }
      },
      "QuoteConvertIn": {
        "type": "object",
        "title": "QuoteConvertIn",
        "description": "Opciones al convertir un presupuesto en factura.",
        "properties": {
          "deposit_percent": {
            "type": [
              "number",
              "null"
            ],
            "exclusiveMinimum": 0,
            "maximum": 100,
            "description": "Porcentaje de anticipo (0 < x <= 100) a facturar. `null` factura el total del presupuesto.",
            "examples": [
              30
            ]
          }
        }
      },
      "QuoteProjectCreateIn": {
        "type": "object",
        "title": "QuoteProjectCreateIn",
        "description": "Opciones al crear el proyecto de un presupuesto aceptado.",
        "properties": {
          "name": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 1,
            "maxLength": 255,
            "description": "Nombre del proyecto. `null` (o ausente) usa el nombre del cliente del presupuesto.",
            "examples": [
              "Rediseño web Acme"
            ]
          },
          "key": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 2,
            "maxLength": 6,
            "description": "Clave del proyecto (2–6 alfanuméricos ASCII, empieza por letra). `null` (o ausente) la deriva del nombre, como en el alta normal de proyectos.",
            "examples": [
              "ACME"
            ]
          }
        }
      },
      "QuoteShareCreateIn": {
        "type": "object",
        "title": "QuoteShareCreateIn",
        "description": "Opciones al generar un token de compartición pública de un presupuesto.",
        "properties": {
          "expires_in_days": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "description": "Días hasta la expiración. `null` = sin expiración.",
            "examples": [
              30
            ]
          }
        }
      },
      "QuoteShareToken": {
        "type": "object",
        "title": "QuoteShareToken",
        "description": "Token que concede acceso de solo lectura a un presupuesto sin autenticación.",
        "required": [
          "id",
          "quote_id",
          "expires_at",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID del registro del token."
          },
          "quote_id": {
            "type": "string",
            "format": "uuid",
            "description": "Presupuesto al que da acceso."
          },
          "token": {
            "type": [
              "string",
              "null"
            ],
            "description": "Valor RAW del token urlsafe; solo presente en la respuesta de creación (POST). Null en otras respuestas — el servidor nunca lo vuelve a exponer.",
            "examples": [
              "pJkT_RaNdOmUrLsAfE32ChArS"
            ]
          },
          "public_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL pública completa para compartir; solo presente en la respuesta de creación.",
            "examples": [
              "https://app.projekt.3xa.es/q/pJkT_RaNdOmUrLsAfE32ChArS"
            ]
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Fecha de expiración (UTC); `null` = sin expiración.",
            "examples": [
              "2026-08-01T00:00:00Z"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-08T10:00:00Z"
            ]
          }
        }
      },
      "PublicQuoteItem": {
        "type": "object",
        "title": "PublicQuoteItem",
        "required": [
          "description",
          "quantity",
          "unit_price",
          "amount"
        ],
        "properties": {
          "description": {
            "type": "string",
            "examples": [
              "Desarrollo web — Sprint 12"
            ]
          },
          "quantity": {
            "type": "number",
            "examples": [
              3
            ]
          },
          "unit_price": {
            "type": "number",
            "examples": [
              250
            ]
          },
          "amount": {
            "type": "number",
            "description": "quantity × unit_price",
            "examples": [
              750
            ]
          }
        }
      },
      "PublicQuote": {
        "type": "object",
        "title": "PublicQuote",
        "description": "Representación de solo lectura de un presupuesto accesible mediante un token público. No expone ids internos ni datos de auditoría.",
        "required": [
          "quote_number",
          "client_name",
          "issue_date",
          "expires_at",
          "status",
          "items",
          "subtotal",
          "tax_rate",
          "tax_amount",
          "total",
          "currency",
          "locale",
          "notes"
        ],
        "properties": {
          "quote_number": {
            "type": "string",
            "examples": [
              "QUO-2026-0042"
            ]
          },
          "client_name": {
            "type": "string",
            "examples": [
              "Tipsterland S.L."
            ]
          },
          "issue_date": {
            "type": "string",
            "format": "date",
            "examples": [
              "2026-07-01"
            ]
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha de caducidad de la oferta; `null` si no caduca.",
            "examples": [
              "2026-07-31"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "sent",
              "approved",
              "rejected",
              "converted",
              "expired"
            ]
          },
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PublicQuoteItem"
            }
          },
          "subtotal": {
            "type": "number",
            "examples": [
              750
            ]
          },
          "tax_rate": {
            "type": "number",
            "examples": [
              21
            ]
          },
          "tax_amount": {
            "type": "number",
            "examples": [
              157.5
            ]
          },
          "total": {
            "type": "number",
            "examples": [
              907.5
            ]
          },
          "currency": {
            "type": "string",
            "default": "EUR",
            "examples": [
              "EUR"
            ]
          },
          "locale": {
            "type": "string",
            "default": "es-ES",
            "description": "Locale BCP-47 de la organización EMISORA, para escribir importes y fechas en su convención. Igual que en `PublicInvoice`: quien abre el enlace no tiene sesión, así que el idioma con el que se le escribe la oferta solo puede venir del propio payload. Se acompaña de `currency` y nada más.",
            "examples": [
              "es-ES",
              "en-US"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "description": "Notas libres; `null` si no se han definido.",
            "examples": [
              "Oferta válida durante 30 días."
            ]
          }
        }
      },
      "DocumentSignature": {
        "type": "object",
        "title": "DocumentSignature",
        "description": "Evidencia probatoria de la firma de un documento.",
        "required": [
          "id",
          "organization_id",
          "subject_type",
          "subject_id",
          "decision",
          "method",
          "signer_name",
          "signer_email",
          "signed_at",
          "document_sha256",
          "document_bytes",
          "evidence_hash",
          "has_signature_image",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "subject_type": {
            "type": "string",
            "description": "Familia del documento firmado.",
            "enum": [
              "quote"
            ]
          },
          "subject_id": {
            "type": "string",
            "format": "uuid",
            "description": "Identificador del documento firmado dentro de su familia."
          },
          "decision": {
            "type": "string",
            "description": "Veredicto del firmante. La firma se registra igual en ambos casos.",
            "enum": [
              "approved",
              "rejected"
            ]
          },
          "method": {
            "type": "string",
            "description": "`handwritten` = trazo con el dedo sobre el lienzo (móvil/tablet); `digital` = aceptación tecleada cuando no hay lienzo disponible. Las dos vías son válidas: lo que da valor probatorio es el código enviado al correo, no el trazo.",
            "enum": [
              "handwritten",
              "digital"
            ]
          },
          "signer_name": {
            "type": "string",
            "description": "Nombre declarado por quien firma."
          },
          "signer_email": {
            "type": "string",
            "format": "email",
            "description": "Correo VERIFICADO: es la dirección a la que se envió el código de seis dígitos y que el firmante demostró leer al teclearlo."
          },
          "signed_at": {
            "type": "string",
            "format": "date-time",
            "description": "Sello de tiempo del SERVIDOR en UTC. Nunca la hora del cliente: el reloj de un navegador lo cambia cualquiera."
          },
          "signer_ip": {
            "type": [
              "string",
              "null"
            ],
            "description": "IP de origen de la firma (puede ser null si no fue resoluble)."
          },
          "signer_user_agent": {
            "type": [
              "string",
              "null"
            ],
            "description": "Agente de usuario declarado por el navegador del firmante."
          },
          "document_sha256": {
            "type": "string",
            "description": "SHA-256 (hex) del PDF EXACTO que el firmante tenía delante al firmar. Ese mismo PDF queda archivado sin tocar; recalcular su huella y compararla con este valor demuestra que no ha cambiado desde la firma.",
            "minLength": 64,
            "maxLength": 64
          },
          "document_bytes": {
            "type": "integer",
            "description": "Tamaño en bytes del PDF firmado."
          },
          "evidence_hash": {
            "type": "string",
            "description": "SHA-256 (hex) sobre el conjunto del expediente (documento + identidad + sello de tiempo + origen). Se copia también al registro de auditoría, así que una manipulación posterior de esta fila deja de cuadrar.",
            "minLength": 64,
            "maxLength": 64
          },
          "has_signature_image": {
            "type": "boolean",
            "description": "Si se guardó el trazo manuscrito junto a la firma."
          },
          "rejection_reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Motivo escrito por el cliente al rechazar (solo en `rejected`)."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PublicDocumentSignature": {
        "type": "object",
        "title": "PublicDocumentSignature",
        "description": "Comprobante de firma para el firmante (sin datos internos).",
        "required": [
          "decision",
          "method",
          "signer_name",
          "signer_email",
          "signed_at",
          "document_sha256"
        ],
        "properties": {
          "decision": {
            "type": "string",
            "enum": [
              "approved",
              "rejected"
            ]
          },
          "method": {
            "type": "string",
            "enum": [
              "handwritten",
              "digital"
            ]
          },
          "signer_name": {
            "type": "string"
          },
          "signer_email": {
            "type": "string",
            "format": "email"
          },
          "signed_at": {
            "type": "string",
            "format": "date-time",
            "description": "Sello de tiempo del servidor (UTC)."
          },
          "document_sha256": {
            "type": "string",
            "description": "Huella SHA-256 (hex) del PDF firmado; consta también en el propio PDF.",
            "minLength": 64,
            "maxLength": 64
          }
        }
      },
      "DocumentSignIn": {
        "type": "object",
        "title": "DocumentSignIn",
        "description": "Firma de un documento con código de un solo uso.",
        "required": [
          "code",
          "signer_name",
          "decision"
        ],
        "properties": {
          "code": {
            "type": "string",
            "description": "Código de seis dígitos recibido por correo. Un solo uso.",
            "pattern": "^[0-9]{6}$",
            "examples": [
              "482913"
            ]
          },
          "signer_name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255,
            "description": "Nombre y apellidos de quien firma.",
            "examples": [
              "Juan Pérez"
            ]
          },
          "decision": {
            "type": "string",
            "description": "Aceptar o rechazar. La firma queda registrada en los dos casos.",
            "enum": [
              "approved",
              "rejected"
            ]
          },
          "method": {
            "type": [
              "string",
              "null"
            ],
            "description": "Vía de firma. `handwritten` exige `signature_image` (el trazo del lienzo); `digital` es la alternativa cuando no hay pantalla táctil. Por defecto se deduce: hay imagen ⇒ manuscrita, no hay ⇒ digital. `null` es lo mismo que omitirlo (el servidor lo deduce igual): el API lo acepta desde siempre y declararlo aquí evita que el SDK prohíba un cuerpo que el servidor admite.",
            "enum": [
              "handwritten",
              "digital",
              null
            ]
          },
          "signature_image": {
            "type": [
              "string",
              "null"
            ],
            "description": "Trazo manuscrito en PNG codificado en base64 (admite el prefijo `data:image/png;base64,`). Máximo 512 KiB ya decodificado; se verifica que los BYTES sean realmente un PNG, no solo su cabecera declarada."
          },
          "reason": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 2000,
            "description": "Motivo del rechazo, escrito por el cliente. Se ignora al aceptar."
          }
        }
      },
      "SignatureCodeSent": {
        "type": "object",
        "title": "SignatureCodeSent",
        "description": "Confirmación de envío del código de firma.",
        "required": [
          "email_hint",
          "expires_in_seconds"
        ],
        "properties": {
          "email_hint": {
            "type": "string",
            "description": "Dirección de destino enmascarada (`j***n@acme.com`).",
            "examples": [
              "j***g@acme.com"
            ]
          },
          "expires_in_seconds": {
            "type": "integer",
            "description": "Vigencia del código desde su emisión.",
            "examples": [
              600
            ]
          }
        }
      },
      "CopilotMessage": {
        "type": "object",
        "title": "CopilotMessage",
        "description": "Mensaje individual de la conversación con el copiloto.",
        "required": [
          "role",
          "content"
        ],
        "properties": {
          "role": {
            "type": "string",
            "enum": [
              "user",
              "assistant"
            ],
            "description": "Autor del mensaje (`user` = persona, `assistant` = copiloto).",
            "examples": [
              "user"
            ]
          },
          "content": {
            "type": "string",
            "description": "Texto del mensaje.",
            "examples": [
              "¿Cuánto IVA soportado llevo este trimestre?"
            ]
          },
          "attachments": {
            "type": "array",
            "default": [],
            "description": "Imágenes adjuntas al mensaje. Solo aplica a mensajes con `role: user`; son las imágenes que Kern lee con visión. En mensajes `assistant` se ignora.",
            "items": {
              "$ref": "#/components/schemas/CopilotAttachment"
            }
          }
        }
      },
      "CopilotAttachment": {
        "type": "object",
        "title": "CopilotAttachment",
        "description": "Imagen adjunta a un mensaje del copiloto (solo mensajes role=user), que Kern interpreta con visión.",
        "required": [
          "media_type",
          "data"
        ],
        "properties": {
          "media_type": {
            "type": "string",
            "enum": [
              "image/png",
              "image/jpeg",
              "image/webp",
              "image/gif"
            ],
            "description": "Tipo MIME de la imagen adjunta.",
            "examples": [
              "image/png"
            ]
          },
          "data": {
            "type": "string",
            "description": "base64 de la imagen, sin el prefijo data:."
          }
        }
      },
      "CopilotChatIn": {
        "type": "object",
        "title": "CopilotChatIn",
        "description": "Historial de la conversación enviado al copiloto.",
        "required": [
          "messages"
        ],
        "properties": {
          "messages": {
            "type": "array",
            "minItems": 1,
            "maxItems": 50,
            "description": "Mensajes del hilo, en orden cronológico (al menos uno, máximo 50).",
            "items": {
              "$ref": "#/components/schemas/CopilotMessage"
            }
          },
          "conversation_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Conversación a continuar (del propio usuario en esta organización). Si se omite, el servidor crea una conversación nueva."
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Solo para una conversación NUEVA (sin `conversation_id`): el proyecto del que va a tratar. Nace asociada a él y Kern lo recibe como contexto de la conversación. Con una conversación existente se ignora — para cambiar la asociación está el PATCH de la conversación. Un proyecto que no existe o que no ves responde 404 ANTES de llamar al modelo, para no gastar nada."
          },
          "client_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Lo mismo con el cliente del que trata. Exige el rol que ve la cartera de clientes (403 si no); 404 si no existe o está en la papelera."
          }
        }
      },
      "CopilotToolCall": {
        "type": "object",
        "title": "CopilotToolCall",
        "description": "Herramienta que el copiloto decidió invocar, con sus argumentos.",
        "required": [
          "name",
          "input"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "Nombre de la herramienta invocada.",
            "examples": [
              "finance_summary"
            ]
          },
          "input": {
            "type": "object",
            "additionalProperties": true,
            "description": "Argumentos pasados a la herramienta (forma libre)."
          }
        }
      },
      "CopilotProposedAction": {
        "type": "object",
        "title": "CopilotProposedAction",
        "description": "Acción de escritura propuesta por el copiloto para que el usuario la confirme.",
        "required": [
          "type",
          "summary",
          "endpoint",
          "method",
          "payload",
          "path_template",
          "path_params"
        ],
        "properties": {
          "type": {
            "type": "string",
            "description": "Tipo de acción, p. ej. 'create_task' | 'create_quote' | 'create_expense'.",
            "examples": [
              "create_task"
            ]
          },
          "summary": {
            "type": "string",
            "description": "Resumen legible en español de lo que se hará.",
            "examples": [
              "Crear tarea \"Revisar contrato\" en el proyecto Kickverse."
            ]
          },
          "endpoint": {
            "type": "string",
            "description": "Ruta REST informativa contra la que ejecutar.",
            "examples": [
              "/tasks"
            ]
          },
          "method": {
            "type": "string",
            "description": "Método HTTP de la operación (POST, PATCH…).",
            "examples": [
              "POST"
            ]
          },
          "path_template": {
            "type": "string",
            "description": "La ruta con sus huecos sin rellenar, tal y como la declara el contrato. `endpoint` es la misma ruta ya rellenada y sirve para enseñarla; ésta es la que el cliente tipado necesita para llamar, porque su tipado va por plantilla. Con las dos, confirmar una propuesta deja de exigir un `case` escrito a mano por cada tipo de acción.",
            "examples": [
              "/api/v1/organizations/{org_id}/projects/{project_id}/tasks"
            ]
          },
          "path_params": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            },
            "description": "Con qué rellenar cada hueco de `path_template`, incluido `org_id`. Van aparte del `payload` porque el cliente tipado los pide aparte, y porque un id de ruta no es un campo del cuerpo aunque se llame igual."
          },
          "payload": {
            "type": "object",
            "additionalProperties": true,
            "description": "Cuerpo que se enviaría al endpoint real."
          }
        }
      },
      "CopilotChat": {
        "type": "object",
        "title": "CopilotChat",
        "description": "Respuesta del copiloto al hilo de conversación.",
        "required": [
          "reply",
          "conversation_id"
        ],
        "properties": {
          "reply": {
            "type": "string",
            "description": "Texto de respuesta del copiloto.",
            "examples": [
              "Llevas 291,81 € de IVA soportado en aprobados este trimestre."
            ]
          },
          "conversation_id": {
            "type": "string",
            "format": "uuid",
            "description": "Conversación donde se persistió este turno (nueva si la petición no traía una); el cliente la reenvía para continuar el hilo."
          },
          "tool_calls": {
            "type": "array",
            "default": [],
            "description": "Herramientas que el copiloto invocó para elaborar la respuesta.",
            "items": {
              "$ref": "#/components/schemas/CopilotToolCall"
            }
          },
          "proposed_actions": {
            "type": "array",
            "default": [],
            "description": "Acciones de escritura propuestas por el copiloto para que el usuario las confirme.",
            "items": {
              "$ref": "#/components/schemas/CopilotProposedAction"
            }
          }
        }
      },
      "CopilotFolder": {
        "type": "object",
        "title": "CopilotFolder",
        "description": "Carpeta para agrupar conversaciones de Kern por tema, como los «Projects» de ChatGPT. Es de UNA persona dentro de una organización: ni admin ni owner ven las de otro, por el mismo motivo que las conversaciones que agrupa —el nombre de una carpeta dice de alguien tanto como el hilo que guarda dentro—.",
        "required": [
          "id",
          "name",
          "position",
          "conversation_count",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID de la carpeta."
          },
          "name": {
            "type": "string",
            "description": "Nombre de la carpeta (1-60 caracteres)."
          },
          "position": {
            "type": "integer",
            "description": "Orden dentro de las carpetas de esta persona. La pone el servidor al crear (al final) y se puede cambiar al reordenar."
          },
          "conversation_count": {
            "type": "integer",
            "description": "Cuántas conversaciones hay dentro. Va en la lista para que la sidebar pueda pintarlo sin una petición por carpeta."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "CopilotFolderCreate": {
        "type": "object",
        "title": "CopilotFolderCreate",
        "description": "El dueño sale de la sesión y la `position` la pone el servidor al final de las suyas; el cuerpo no admite ninguna de las dos.",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 60,
            "description": "Nombre de la carpeta."
          }
        }
      },
      "CopilotFolderUpdate": {
        "type": "object",
        "title": "CopilotFolderUpdate",
        "description": "Renombrarla o moverla de sitio. Sin campos no cambia nada — un PATCH vacío es un 200 con la carpeta tal cual, no un error: quien lo manda no ha pedido nada imposible.",
        "properties": {
          "name": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 1,
            "maxLength": 60
          },
          "position": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0
          }
        }
      },
      "CopilotConversationUpdate": {
        "type": "object",
        "title": "CopilotConversationUpdate",
        "description": "Actualización parcial. Un campo omitido no se toca; `null` explícito lo vacía: `folder_id: null` devuelve la conversación a la lista suelta, `project_id: null` la desasocia del proyecto y `client_id: null` del cliente. Los tres son destinos de verdad y no «ninguno», así que se dicen mandando `null` y no omitiendo el campo.",
        "properties": {
          "folder_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID de una carpeta TUYA, o `null` para la lista suelta. Una carpeta de otra persona responde 404, igual que su conversación."
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID de un proyecto de la organización que esta persona puede abrir, o `null` para desasociarla. Un proyecto que no existe o que no ves responde 404, igual que su ficha."
          },
          "client_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID de un cliente de la organización, o `null` para desasociarla. Exige el rol que ve la cartera de clientes (403 si no), y responde 404 si no existe o está en la papelera, igual que su ficha."
          }
        }
      },
      "CopilotConversation": {
        "type": "object",
        "title": "CopilotConversation",
        "description": "Conversación guardada del copiloto Kern.",
        "required": [
          "id",
          "created_at",
          "updated_at",
          "message_count"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID de la conversación."
          },
          "title": {
            "type": [
              "string",
              "null"
            ],
            "description": "Resumen del primer mensaje del usuario, o `null`."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "Fecha de creación de la conversación."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "Fecha del último mensaje de la conversación."
          },
          "message_count": {
            "type": "integer",
            "description": "Número de mensajes (turnos) guardados en la conversación."
          },
          "folder_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "La carpeta que la agrupa, o `null` para la lista suelta. Nullable a propósito y sin backfill: las conversaciones que ya existían se quedan sueltas, que es su sitio correcto."
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "El proyecto al que está asociada, o `null`. Nullable y sin backfill, como la carpeta: una conversación nace sin proyecto y se asocia a mano, o desde la pestaña «Kern» de la ficha del proyecto."
          },
          "client_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "El cliente al que está asociada, o `null`."
          }
        }
      },
      "CopilotConvMessage": {
        "type": "object",
        "title": "CopilotConvMessage",
        "description": "Mensaje guardado de una conversación del copiloto Kern.",
        "required": [
          "id",
          "role",
          "content",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID del mensaje."
          },
          "role": {
            "type": "string",
            "enum": [
              "user",
              "assistant"
            ],
            "description": "Autor del mensaje (`user` = persona, `assistant` = copiloto).",
            "examples": [
              "user"
            ]
          },
          "content": {
            "type": "string",
            "description": "Texto del mensaje."
          },
          "tool_calls": {
            "type": "array",
            "default": [],
            "description": "Nombres de las tools ejecutadas en el turno (vacío si ninguna).",
            "items": {
              "type": "string"
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "Fecha en que se guardó el mensaje."
          }
        }
      },
      "KernAlert": {
        "type": "object",
        "title": "KernAlert",
        "description": "Aviso accionable generado por el copiloto Kern para la organización.",
        "required": [
          "module",
          "severity",
          "title",
          "detail"
        ],
        "properties": {
          "module": {
            "type": "string",
            "enum": [
              "finance",
              "quotes",
              "projects",
              "tasks"
            ],
            "description": "Módulo de la aplicación al que pertenece el aviso.",
            "examples": [
              "finance"
            ]
          },
          "severity": {
            "type": "string",
            "enum": [
              "info",
              "warn",
              "urgent"
            ],
            "description": "Nivel de urgencia del aviso.",
            "examples": [
              "urgent"
            ]
          },
          "title": {
            "type": "string",
            "description": "Titular corto del aviso.",
            "examples": [
              "Facturas vencidas"
            ]
          },
          "detail": {
            "type": "string",
            "description": "Descripción del aviso.",
            "examples": [
              "Tienes 3 facturas vencidas por un total de 4.210,00 €."
            ]
          },
          "action_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL relativa para actuar sobre el aviso, o `null` si no hay acción.",
            "examples": [
              "/finance/invoices?status=overdue"
            ]
          }
        }
      },
      "PortalLinkCreate": {
        "type": "object",
        "title": "PortalLinkCreate",
        "description": "Opciones al generar un enlace de portal de cliente.",
        "properties": {
          "expires_in_days": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "description": "Días hasta la expiración. `null` = sin expiración.",
            "examples": [
              30
            ]
          }
        }
      },
      "PortalLink": {
        "type": "object",
        "title": "PortalLink",
        "description": "Enlace que concede al cliente acceso de solo lectura a su portal sin autenticación.",
        "required": [
          "id",
          "client_id",
          "expires_at",
          "revoked_at",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID del registro del enlace.",
            "examples": [
              "5d4c3b2a-1098-4765-bade-f01234567890"
            ]
          },
          "client_id": {
            "type": "string",
            "format": "uuid",
            "description": "Cliente al que da acceso.",
            "examples": [
              "3fa85f64-5717-4562-b3fc-2c963f66afa6"
            ]
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Fecha de expiración (UTC); `null` = sin expiración.",
            "examples": [
              "2026-08-01T00:00:00Z"
            ]
          },
          "revoked_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Momento (UTC) en que se revocó el enlace; `null` si sigue activo.",
            "examples": [
              "2026-07-20T12:00:00Z"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-08T10:00:00Z"
            ]
          },
          "token": {
            "type": [
              "string",
              "null"
            ],
            "description": "Valor RAW del token urlsafe; solo presente en la respuesta de creación (POST). Null en otras respuestas — el servidor nunca lo vuelve a exponer.",
            "examples": [
              "pJkT_RaNdOmUrLsAfE32ChArS"
            ]
          },
          "portal_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL pública completa del portal; solo presente en la respuesta de creación.",
            "examples": [
              "https://app.projekt.3xa.es/portal/pJkT_RaNdOmUrLsAfE32ChArS"
            ]
          }
        }
      },
      "PortalOrgBrand": {
        "type": "object",
        "title": "PortalOrgBrand",
        "description": "Datos de marca (nombre, logo y colores) de la organización emisora, para el portal público.",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "Nombre de la organización.",
            "examples": [
              "Tipsterland S.L."
            ]
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL del logotipo de la organización; `null` si no se ha definido.",
            "examples": [
              "https://app.projekt.3xa.es/uploads/logos/tipsterland.png"
            ]
          },
          "brand_color": {
            "type": [
              "string",
              "null"
            ],
            "description": "Color de marca principal (hex); `null` si no se ha definido.",
            "examples": [
              "#fd2554"
            ]
          },
          "accent_color": {
            "type": [
              "string",
              "null"
            ],
            "description": "Color de acento (hex); `null` si no se ha definido.",
            "examples": [
              "#0b7a2d"
            ]
          },
          "legal_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Razón social legal de la organización; `null` si no se ha definido.",
            "examples": [
              "Tipsterland Sociedad Limitada"
            ]
          }
        }
      },
      "PortalSummary": {
        "type": "object",
        "title": "PortalSummary",
        "description": "Vista de resumen del portal de un cliente (marca de la organización y recuentos de facturas, presupuestos y contratos).",
        "required": [
          "client_name",
          "org",
          "locale",
          "currency",
          "invoice_count",
          "quote_count",
          "contract_count"
        ],
        "properties": {
          "client_name": {
            "type": "string",
            "description": "Nombre del cliente propietario del portal.",
            "examples": [
              "Tipsterland S.L."
            ]
          },
          "org": {
            "$ref": "#/components/schemas/PortalOrgBrand"
          },
          "locale": {
            "type": "string",
            "default": "es-ES",
            "description": "Locale BCP-47 de la organización EMISORA, para escribir las fechas y los importes de TODO el portal en su convención. Va en el resumen y no en cada documento porque el portal entero pertenece a una sola organización: el idioma no cambia de fila en fila.",
            "examples": [
              "es-ES",
              "en-US"
            ]
          },
          "currency": {
            "type": "string",
            "default": "EUR",
            "description": "Divisa ISO 4217 BASE de la organización emisora. Es el respaldo para los importes cuya divisa no viaja —el valor de un contrato la tiene a `null`—, NO una conversión: cada factura y cada presupuesto se pintan con la suya y en el portal pueden convivir varias.",
            "examples": [
              "EUR",
              "USD"
            ]
          },
          "invoice_count": {
            "type": "integer",
            "description": "Número de facturas visibles en el portal.",
            "examples": [
              12
            ]
          },
          "quote_count": {
            "type": "integer",
            "description": "Número de presupuestos visibles en el portal.",
            "examples": [
              4
            ]
          },
          "contract_count": {
            "type": "integer",
            "description": "Número de contratos visibles en el portal.",
            "examples": [
              2
            ]
          }
        }
      },
      "PortalInvoice": {
        "type": "object",
        "title": "PortalInvoice",
        "description": "Representación resumida de una factura tal como se muestra en el portal del cliente.",
        "required": [
          "invoice_number",
          "issue_date",
          "due_date",
          "status",
          "total",
          "currency"
        ],
        "properties": {
          "invoice_number": {
            "type": "string",
            "examples": [
              "INV-2026-0042"
            ]
          },
          "issue_date": {
            "type": "string",
            "format": "date",
            "description": "Fecha de emisión.",
            "examples": [
              "2026-07-01"
            ]
          },
          "due_date": {
            "type": "string",
            "format": "date",
            "description": "Fecha de vencimiento.",
            "examples": [
              "2026-07-31"
            ]
          },
          "status": {
            "type": "string",
            "examples": [
              "sent"
            ]
          },
          "total": {
            "type": "number",
            "description": "Importe total de la factura.",
            "examples": [
              907.5
            ]
          },
          "currency": {
            "type": "string",
            "description": "Código ISO 4217 de la moneda.",
            "examples": [
              "EUR"
            ]
          },
          "approved_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Momento en que la factura fue aprobada desde el portal; `null` si aún no se ha aprobado.",
            "examples": [
              "2026-07-15T10:30:00Z"
            ]
          },
          "approved_by_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nombre de quien aprobó la factura desde el portal; `null` si aún no se ha aprobado.",
            "examples": [
              "Juan Pérez"
            ]
          }
        }
      },
      "PortalQuote": {
        "type": "object",
        "title": "PortalQuote",
        "description": "Representación resumida de un presupuesto tal como se muestra en el portal del cliente.",
        "required": [
          "quote_number",
          "issue_date",
          "status",
          "total",
          "currency"
        ],
        "properties": {
          "quote_number": {
            "type": "string",
            "examples": [
              "QUO-2026-0042"
            ]
          },
          "issue_date": {
            "type": "string",
            "format": "date",
            "description": "Fecha de emisión.",
            "examples": [
              "2026-07-01"
            ]
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha de caducidad de la oferta; `null` si no caduca.",
            "examples": [
              "2026-07-31"
            ]
          },
          "status": {
            "type": "string",
            "examples": [
              "sent"
            ]
          },
          "total": {
            "type": "number",
            "description": "Total ofertado.",
            "examples": [
              907.5
            ]
          },
          "currency": {
            "type": "string",
            "description": "Código ISO 4217 de la moneda.",
            "examples": [
              "EUR"
            ]
          }
        }
      },
      "PortalContract": {
        "type": "object",
        "title": "PortalContract",
        "description": "Representación resumida de un contrato tal como se muestra en el portal del cliente.",
        "required": [
          "contract_number",
          "title",
          "status"
        ],
        "properties": {
          "contract_number": {
            "type": "string",
            "examples": [
              "CT-2026-0042"
            ]
          },
          "title": {
            "type": "string",
            "examples": [
              "Contrato de servicios de desarrollo"
            ]
          },
          "status": {
            "type": "string",
            "examples": [
              "active"
            ]
          },
          "value": {
            "type": [
              "number",
              "null"
            ],
            "description": "Valor económico del contrato; `null` si no se ha definido.",
            "examples": [
              12000
            ]
          },
          "currency": {
            "type": [
              "string",
              "null"
            ],
            "description": "Código ISO 4217 de la moneda; `null` si no se ha definido.",
            "examples": [
              "EUR"
            ]
          },
          "start_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha de inicio; `null` si no se ha definido.",
            "examples": [
              "2026-07-01"
            ]
          },
          "end_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha de fin; `null` si no se ha definido.",
            "examples": [
              "2026-12-31"
            ]
          },
          "signed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Momento en que el contrato fue firmado desde el portal; `null` si aún no se ha firmado.",
            "examples": [
              "2026-07-15T10:30:00Z"
            ]
          },
          "signed_by_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nombre de quien firmó el contrato desde el portal; `null` si aún no se ha firmado.",
            "examples": [
              "Juan Pérez"
            ]
          }
        }
      },
      "PortalApprove": {
        "type": "object",
        "title": "PortalApprove",
        "description": "Datos de aprobación/firma pública de un documento desde el portal del cliente.",
        "required": [
          "signer_name"
        ],
        "properties": {
          "signer_name": {
            "type": "string",
            "description": "Nombre de quien aprueba la factura o firma el contrato.",
            "examples": [
              "Juan Pérez"
            ]
          },
          "signer_email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "description": "Email de quien aprueba o firma; opcional.",
            "examples": [
              "juan@tipsterland.es"
            ]
          }
        }
      },
      "Client": {
        "type": "object",
        "title": "Client",
        "description": "Cliente (CRM) registrado por una organización.",
        "required": [
          "id",
          "organization_id",
          "name",
          "logo_url",
          "client_type",
          "email",
          "phone",
          "company",
          "tax_id",
          "address",
          "city",
          "country",
          "postal_code",
          "notes",
          "is_active",
          "linked_org_id",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "9c8b7a65-4321-4fed-cba9-876543210fed"
            ]
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          },
          "name": {
            "type": "string",
            "examples": [
              "Acme Corp"
            ]
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL del logo del cliente; `null` si no se ha indicado.",
            "examples": [
              "https://cdn.example.com/logos/acme.png"
            ]
          },
          "client_type": {
            "type": "string",
            "enum": [
              "legal",
              "individual"
            ],
            "description": "Persona jurídica (`legal`) o física (`individual`)."
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "description": "Email de contacto; `null` si no se ha indicado.",
            "examples": [
              "billing@acme.com"
            ]
          },
          "phone": {
            "type": [
              "string",
              "null"
            ],
            "description": "Teléfono de contacto; `null` si no se ha indicado.",
            "examples": [
              "+34 600 000 000"
            ]
          },
          "company": {
            "type": [
              "string",
              "null"
            ],
            "description": "Razón social / empresa; `null` si no se ha indicado.",
            "examples": [
              "Acme Corporation"
            ]
          },
          "tax_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "NIF/CIF/VAT; `null` si no se ha indicado.",
            "examples": [
              "B12345678"
            ]
          },
          "address": {
            "type": [
              "string",
              "null"
            ],
            "description": "Dirección postal; `null` si no se ha indicado.",
            "examples": [
              "Calle Falsa 123"
            ]
          },
          "city": {
            "type": [
              "string",
              "null"
            ],
            "description": "Ciudad; `null` si no se ha indicado.",
            "examples": [
              "Madrid"
            ]
          },
          "country": {
            "type": [
              "string",
              "null"
            ],
            "description": "País; `null` si no se ha indicado.",
            "examples": [
              "España"
            ]
          },
          "postal_code": {
            "type": [
              "string",
              "null"
            ],
            "description": "Código postal; `null` si no se ha indicado.",
            "examples": [
              "28001"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "description": "Notas libres; `null` si no se han definido.",
            "examples": [
              "Cliente principal."
            ]
          },
          "is_active": {
            "type": "boolean",
            "description": "Cliente activo.",
            "default": true
          },
          "linked_org_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID de la organización Projekt vinculada a este cliente CRM; `null` si no se ha establecido el vínculo."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-05T14:00:00Z"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-05T14:15:00Z"
            ]
          }
        }
      },
      "Supplier": {
        "type": "object",
        "title": "Supplier",
        "description": "Proveedor registrado por una organización.",
        "required": [
          "id",
          "organization_id",
          "name",
          "tax_id",
          "email",
          "phone",
          "address",
          "logo_url",
          "notes",
          "global_supplier_id",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "3f1c2d4e-5b6a-7c8d-9e0f-1a2b3c4d5e6f"
            ]
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          },
          "name": {
            "type": "string",
            "examples": [
              "Acme Proveedor SL"
            ]
          },
          "tax_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "NIF/CIF/VAT del proveedor. Obligatorio al crear o editar la ficha, pero la respuesta lo declara nullable porque siguen existiendo fichas creadas antes de que la regla entrara en vigor.",
            "examples": [
              "B12345678"
            ]
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "description": "Email de contacto; `null` si no se ha indicado.",
            "examples": [
              "facturas@acme-prov.es"
            ]
          },
          "phone": {
            "type": [
              "string",
              "null"
            ],
            "description": "Teléfono de contacto; `null` si no se ha indicado.",
            "examples": [
              "+34 600 111 222"
            ]
          },
          "address": {
            "type": [
              "string",
              "null"
            ],
            "description": "Dirección postal; `null` si no se ha indicado.",
            "examples": [
              "Calle Mayor 1, Madrid"
            ]
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL del logo del proveedor; `null` si no se ha indicado.",
            "examples": [
              "https://cdn.example.com/logos/acme-prov.png"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "description": "Notas libres; `null` si no se han definido."
          },
          "global_supplier_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Entrada del catálogo global de la que se copió este proveedor; `null` si se creó a mano. Vínculo informativo (los datos org-scoped son editables)."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-13T10:00:00Z"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-13T10:15:00Z"
            ]
          }
        }
      },
      "SupplierWithStats": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Supplier"
          },
          {
            "type": "object",
            "title": "SupplierWithStats",
            "description": "Proveedor + agregados combinados: facturas de proveedor (AP) y gastos aprobados cuyo vendor coincide con el nombre del proveedor.",
            "required": [
              "invoice_count",
              "total_spend",
              "expense_count",
              "expense_total",
              "spend_total"
            ],
            "properties": {
              "currency": {
                "type": "string",
                "minLength": 3,
                "maxLength": 3,
                "description": "Divisa ISO 4217 de los tres importes de esta fila — la base de la organización. Los agregados suman SOLO documentos registrados en ella y los recuentos se acotan con ellos. Antes eran `SUM(...)` ciegos, así que «lo gastado con este proveedor» mezclaba monedas — y como `spend_total` ordena el top de proveedores, esa mezcla decidía además quién salía primero. No se convierte nada; lo registrado en otra divisa queda fuera.",
                "examples": [
                  "USD"
                ]
              },
              "invoice_count": {
                "type": "integer",
                "description": "Número de facturas de proveedor enlazadas a este proveedor (por `supplier_id` FK o por coincidencia de nombre si la factura es anterior a la entidad) que suman en `total_spend`: mismo `currency` y mismos estados. Contaba también las `void`/`rejected`, que sí salían del importe, así que un proveedor con todas sus facturas anuladas aparecía como «2 fac. · 0,00 €».",
                "examples": [
                  12
                ]
              },
              "total_spend": {
                "type": "number",
                "description": "Suma del campo `total` de las facturas AP enlazadas, en `currency`, descontando las anuladas (`void`) y rechazadas (`rejected`). Cero si no hay facturas contables.",
                "examples": [
                  14520
                ]
              },
              "expense_count": {
                "type": "integer",
                "description": "Número de gastos aprobados cuyo campo `vendor` coincide con el nombre de este proveedor (case-insensitive, exacto primero; si no, parcial con desempate por nombre más largo).",
                "examples": [
                  5
                ]
              },
              "expense_total": {
                "type": "number",
                "description": "Suma del campo `amount` de los gastos aprobados coincidentes, en `currency`. Cero si no hay gastos.",
                "examples": [
                  3200
                ]
              },
              "spend_total": {
                "type": "number",
                "description": "Gasto combinado = `total_spend` (facturas AP) + `expense_total` (gastos aprobados). Es el importe que aparece en el dashboard y el que ordena el \"top por gasto\".",
                "examples": [
                  17720
                ]
              }
            }
          }
        ]
      },
      "SupplierCleanupResult": {
        "type": "object",
        "title": "SupplierCleanupResult",
        "description": "Resultado de la purga de proveedores fantasma (sin gastos ni facturas AP vinculadas).",
        "required": [
          "deleted",
          "kept"
        ],
        "properties": {
          "deleted": {
            "type": "integer",
            "minimum": 0,
            "description": "Número de proveedores eliminados.",
            "examples": [
              3
            ]
          },
          "kept": {
            "type": "integer",
            "minimum": 0,
            "description": "Número de proveedores conservados (tienen al menos un gasto o factura AP).",
            "examples": [
              7
            ]
          }
        }
      },
      "MemberBulkUpdateIn": {
        "type": "object",
        "title": "MemberBulkUpdateIn",
        "description": "Actualización masiva de miembros. `department_id`, `position`, `manager_id` e `is_active` son opcionales pero al menos uno debe estar presente. Requiere manager+.",
        "required": [
          "user_ids"
        ],
        "properties": {
          "user_ids": {
            "type": "array",
            "minItems": 1,
            "maxItems": 200,
            "description": "IDs de usuarios (miembros de la organización) a actualizar.",
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "examples": [
              [
                "a1b2c3d4-e5f6-7a8b-9c0d-e1f2a3b4c5d6",
                "b2c3d4e5-f6a7-8b9c-0d1e-f2a3b4c5d6e7"
              ]
            ]
          },
          "department_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del departamento a asignar; `null` desvincula el departamento. Sincroniza `employees.department` (string) con el nombre del departamento.",
            "examples": [
              "c3d4e5f6-a7b8-9c0d-1e2f-a3b4c5d6e7f8"
            ]
          },
          "position": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 120,
            "description": "Cargo o posición; `null` borra el valor.",
            "examples": [
              "Desarrollador Senior"
            ]
          },
          "manager_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID de la ficha employee del responsable a asignar a TODOS los seleccionados; `null` quita el responsable. Por cada miembro se omite (con motivo `self_manager` o `manager_cycle`) si sería su propia ficha o crearía un ciclo en la jerarquía.",
            "examples": [
              "c3d4e5f6-a7b8-9c0d-1e2f-a3b4c5d6e7f8"
            ]
          },
          "is_active": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Desactivar (`false`) o reactivar (`true`) en masa a los seleccionados en su ficha employee. Al desactivar, el miembro NO se elimina de la organización: su membership y su ficha RRHH se conservan. `null` es lo mismo que omitirlo (`data.is_active is not None` decide si se toca), al contrario que en `department_id`/`manager_id`, donde `null` SÍ desvincula.",
            "examples": [
              false
            ]
          }
        }
      },
      "MemberBulkResult": {
        "type": "object",
        "title": "MemberBulkResult",
        "description": "Resultado de la actualización masiva de miembros.",
        "required": [
          "ok",
          "errors"
        ],
        "properties": {
          "ok": {
            "type": "integer",
            "minimum": 0,
            "description": "Número de miembros actualizados con éxito.",
            "examples": [
              5
            ]
          },
          "errors": {
            "type": "array",
            "description": "Lista de errores para los miembros que no pudieron actualizarse.",
            "items": {
              "type": "object",
              "required": [
                "user_id",
                "reason"
              ],
              "properties": {
                "user_id": {
                  "type": "string",
                  "format": "uuid",
                  "description": "ID del usuario que falló.",
                  "examples": [
                    "a1b2c3d4-e5f6-7a8b-9c0d-e1f2a3b4c5d6"
                  ]
                },
                "reason": {
                  "type": "string",
                  "description": "Descripción del error (p.ej. \"not_a_member\", \"department_not_found\").",
                  "examples": [
                    "not_a_member"
                  ]
                }
              }
            }
          }
        }
      },
      "Pipeline": {
        "type": "object",
        "title": "Pipeline",
        "description": "Embudo de ventas de una organización con sus etapas ordenadas. Al listar pipelines de una organización sin ninguno, el servidor siembra automáticamente un pipeline \"Ventas\" por defecto (create-on-read).",
        "required": [
          "id",
          "organization_id",
          "name",
          "is_default",
          "created_at",
          "stages"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "examples": [
              "Ventas"
            ]
          },
          "is_default": {
            "type": "boolean",
            "description": "Pipeline por defecto de la organización.",
            "default": false
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "stages": {
            "type": "array",
            "description": "Etapas del pipeline, ordenadas por `position`.",
            "items": {
              "$ref": "#/components/schemas/Stage"
            }
          }
        }
      },
      "Stage": {
        "type": "object",
        "title": "Stage",
        "description": "Etapa ordenada (columna del tablero kanban) de un pipeline de CRM.",
        "required": [
          "id",
          "pipeline_id",
          "name",
          "position",
          "probability",
          "is_won",
          "is_lost",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "pipeline_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "examples": [
              "Propuesta"
            ]
          },
          "position": {
            "type": "integer",
            "description": "Posición 0-based de la columna en el tablero.",
            "examples": [
              2
            ]
          },
          "probability": {
            "type": "integer",
            "description": "Probabilidad por defecto de los deals en esta etapa (0-100).",
            "minimum": 0,
            "maximum": 100,
            "examples": [
              50
            ]
          },
          "is_won": {
            "type": "boolean",
            "description": "La etapa representa un deal ganado.",
            "default": false
          },
          "is_lost": {
            "type": "boolean",
            "description": "La etapa representa un deal perdido.",
            "default": false
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Deal": {
        "type": "object",
        "title": "Deal",
        "description": "Oportunidad de venta situada en una etapa de un pipeline.",
        "required": [
          "id",
          "organization_id",
          "pipeline_id",
          "stage_id",
          "title",
          "client_id",
          "value",
          "currency",
          "probability",
          "expected_close_date",
          "owner_id",
          "status",
          "lost_reason",
          "converted_project_id",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "pipeline_id": {
            "type": "string",
            "format": "uuid"
          },
          "stage_id": {
            "type": "string",
            "format": "uuid"
          },
          "title": {
            "type": "string",
            "examples": [
              "Renovación anual Acme"
            ]
          },
          "client_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Cliente asociado; `null` si no se ha vinculado."
          },
          "value": {
            "type": "number",
            "description": "Importe estimado del deal.",
            "default": 0,
            "examples": [
              12500
            ]
          },
          "currency": {
            "type": "string",
            "description": "Código de moneda ISO 4217 (3 letras).",
            "default": "EUR",
            "examples": [
              "EUR"
            ]
          },
          "probability": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 100,
            "description": "Probabilidad de cierre (0-100); si no se fija, hereda la de la etapa al crear o mover el deal.",
            "examples": [
              50
            ]
          },
          "expected_close_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha estimada de cierre; `null` si no se ha indicado."
          },
          "owner_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Usuario responsable del deal; `null` si no se ha asignado."
          },
          "status": {
            "type": "string",
            "enum": [
              "open",
              "won",
              "lost"
            ],
            "description": "Estado del deal.",
            "default": "open"
          },
          "lost_reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Motivo de pérdida; `null` salvo en deals `lost`."
          },
          "converted_project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Proyecto creado a partir de este deal vía el handoff comercial→entrega (`POST .../deals/{deal_id}/convert-to-project`). `null` si el deal aún no se ha convertido. Sirve de traza y de guarda de idempotencia: reconvertir un deal ya convertido devuelve este mismo proyecto sin duplicar.",
            "examples": [
              "2b9d1e0f-6a7c-4d8e-9f01-234567890abc"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Lead": {
        "type": "object",
        "title": "Lead",
        "description": "Contacto potencial (pre-cliente) de una organización.",
        "required": [
          "id",
          "organization_id",
          "name",
          "email",
          "phone",
          "company",
          "source",
          "status",
          "notes",
          "converted_client_id",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "examples": [
              "Jane Prospect"
            ]
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "description": "Email de contacto; `null` si no se ha indicado."
          },
          "phone": {
            "type": [
              "string",
              "null"
            ],
            "description": "Teléfono de contacto; `null` si no se ha indicado."
          },
          "company": {
            "type": [
              "string",
              "null"
            ],
            "description": "Empresa; `null` si no se ha indicado."
          },
          "source": {
            "type": [
              "string",
              "null"
            ],
            "description": "Origen del lead (web, referido, evento…); `null` si no se ha indicado.",
            "examples": [
              "web"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "new",
              "contacted",
              "qualified",
              "converted",
              "lost"
            ],
            "description": "Estado del lead.",
            "default": "new"
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "description": "Notas libres; `null` si no se han definido."
          },
          "converted_client_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Cliente creado al convertir el lead; `null` mientras no se convierte."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "CrmActivity": {
        "type": "object",
        "title": "CrmActivity",
        "description": "Interacción (llamada, email, reunión, nota o tarea) atada a un deal o lead.",
        "required": [
          "id",
          "organization_id",
          "deal_id",
          "lead_id",
          "type",
          "body",
          "created_by",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "deal_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Deal asociado; `null` si la actividad es de un lead."
          },
          "lead_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Lead asociado; `null` si la actividad es de un deal."
          },
          "type": {
            "type": "string",
            "enum": [
              "call",
              "email",
              "meeting",
              "note",
              "task"
            ],
            "description": "Tipo de interacción."
          },
          "body": {
            "type": "string",
            "examples": [
              "Llamada de seguimiento; interesado en el plan anual."
            ]
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Usuario que registró la actividad; `null` si el usuario se eliminó."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PipelineMetric": {
        "type": "object",
        "title": "PipelineMetric",
        "description": "Recuento e importe (bruto y ponderado por probabilidad) de los deals abiertos de una etapa. Incluye etapas sin deals (count 0).",
        "required": [
          "stage_id",
          "stage",
          "count",
          "value",
          "weighted_value"
        ],
        "properties": {
          "stage_id": {
            "type": "string",
            "format": "uuid"
          },
          "stage": {
            "type": "string",
            "description": "Nombre de la etapa.",
            "examples": [
              "Propuesta"
            ]
          },
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 de los importes de esta etapa — la base de la organización. Solo se agregan deals abiertos en ella: antes era un `SUM(value)` ciego a `deals.currency`, es decir el valor del pipeline de una organización con oportunidades en dos monedas expresado como un solo importe (y `weighted_value` heredaba la mezcla). No se convierte nada; lo abierto en otra divisa queda fuera, y el recuento se acota con el importe porque dice de cuántas oportunidades sale.",
            "examples": [
              "USD"
            ]
          },
          "count": {
            "type": "integer",
            "description": "Número de deals abiertos en la etapa.",
            "examples": [
              4
            ]
          },
          "value": {
            "type": "number",
            "description": "Suma del valor de los deals abiertos de la etapa.",
            "examples": [
              12000
            ]
          },
          "weighted_value": {
            "type": "number",
            "description": "Suma de `value * probabilidad/100` (probabilidad del deal o de la etapa).",
            "examples": [
              6000
            ]
          }
        }
      },
      "FunnelStage": {
        "type": "object",
        "title": "FunnelStage",
        "description": "Etapa ordenada del embudo con recuento de deals abiertos y drop-off (deals perdidos respecto a la etapa inmediatamente anterior; 0 en la primera).",
        "required": [
          "stage_id",
          "stage",
          "position",
          "count",
          "value",
          "drop_off"
        ],
        "properties": {
          "stage_id": {
            "type": "string",
            "format": "uuid"
          },
          "stage": {
            "type": "string",
            "description": "Nombre de la etapa."
          },
          "position": {
            "type": "integer",
            "description": "Posición 0-based de la etapa en el embudo."
          },
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 del importe de esta etapa — la base de la organización. Solo se agregan deals abiertos en ella; no se convierte nada. El recuento (y con él el `drop_off`) mide el mismo conjunto que el importe.",
            "examples": [
              "USD"
            ]
          },
          "count": {
            "type": "integer",
            "description": "Número de deals abiertos en la etapa."
          },
          "value": {
            "type": "number",
            "description": "Suma del valor de los deals abiertos de la etapa."
          },
          "drop_off": {
            "type": "integer",
            "description": "Caída de recuento respecto a la etapa anterior (0 en la primera).",
            "examples": [
              1
            ]
          }
        }
      },
      "CrmSummary": {
        "type": "object",
        "title": "CrmSummary",
        "description": "Agregados de CRM de una organización: pipeline abierto (recuento e importe), negocio ganado, tasa de conversión y leads por estado.",
        "required": [
          "open_deals",
          "open_value",
          "won_value",
          "won_count",
          "lost_count",
          "win_rate",
          "leads_by_status"
        ],
        "properties": {
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 de `open_value` y `won_value` — la base de la organización. Solo se agregan deals en ella; no se convierte nada. Los recuentos de deals (y por tanto `win_rate`) se acotan con los importes: miden el mismo conjunto. `leads_by_status` no lleva dinero y cuenta todos los leads.",
            "examples": [
              "USD"
            ]
          },
          "open_deals": {
            "type": "integer",
            "description": "Número de deals abiertos.",
            "examples": [
              7
            ]
          },
          "open_value": {
            "type": "number",
            "description": "Suma del valor de los deals abiertos.",
            "examples": [
              42000
            ]
          },
          "won_value": {
            "type": "number",
            "description": "Suma del valor de los deals ganados.",
            "examples": [
              18000
            ]
          },
          "won_count": {
            "type": "integer",
            "description": "Número de deals ganados.",
            "examples": [
              5
            ]
          },
          "lost_count": {
            "type": "integer",
            "description": "Número de deals perdidos.",
            "examples": [
              3
            ]
          },
          "win_rate": {
            "type": "number",
            "format": "float",
            "description": "Ratio de conversión = ganados / (ganados + perdidos); 0 si no hay cerrados.",
            "minimum": 0,
            "maximum": 1,
            "examples": [
              0.625
            ]
          },
          "leads_by_status": {
            "type": "array",
            "description": "Recuento de leads por estado.",
            "items": {
              "type": "object",
              "title": "LeadStatusStat",
              "required": [
                "status",
                "count"
              ],
              "properties": {
                "status": {
                  "type": "string",
                  "enum": [
                    "new",
                    "contacted",
                    "qualified",
                    "converted",
                    "lost"
                  ]
                },
                "count": {
                  "type": "integer"
                }
              }
            }
          }
        }
      },
      "OrgSummary": {
        "type": "object",
        "title": "OrgSummary",
        "description": "Datos mínimos de una organización candidata a enlazar.",
        "required": [
          "id",
          "name",
          "slug"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "examples": [
              "Acme Inc"
            ]
          },
          "slug": {
            "type": "string",
            "examples": [
              "acme-inc"
            ]
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL del logotipo de la organización; null si no tiene."
          }
        }
      },
      "OrgLink": {
        "type": "object",
        "title": "OrgLink",
        "description": "Enlace entre dos organizaciones. `pending` mientras el target no responde; `accepted` habilita compartir proyectos en ambos sentidos; `rejected`/`revoked` son estados terminales. `direction` es `incoming` si la org que consulta es el target, `outgoing` si es el requester.",
        "required": [
          "id",
          "requester_org_id",
          "target_org_id",
          "other_org_id",
          "other_org_name",
          "other_org_slug",
          "direction",
          "status",
          "requested_by",
          "responded_by",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "requester_org_id": {
            "type": "string",
            "format": "uuid"
          },
          "target_org_id": {
            "type": "string",
            "format": "uuid"
          },
          "other_org_id": {
            "type": "string",
            "format": "uuid",
            "description": "Organización al OTRO lado del enlace (según quién consulta)."
          },
          "other_org_name": {
            "type": "string"
          },
          "other_org_slug": {
            "type": "string"
          },
          "other_org_logo_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL del logotipo de la otra organización; null si no tiene."
          },
          "direction": {
            "type": "string",
            "enum": [
              "incoming",
              "outgoing"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "accepted",
              "rejected",
              "revoked"
            ]
          },
          "requested_by": {
            "type": "string",
            "format": "uuid",
            "description": "Usuario (de la org solicitante) que creó la solicitud."
          },
          "responded_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Usuario que aceptó/rechazó/revocó; `null` mientras sigue pendiente."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ProjectShare": {
        "type": "object",
        "title": "ProjectShare",
        "description": "Un proyecto compartido por su organización dueña con otra organización. Solo es válido si existe un `OrgLink` aceptado entre ambas. `permission` `read` concede solo lectura; `full` reserva escritura (no expuesta aún, ver docs).",
        "required": [
          "id",
          "project_id",
          "owner_org_id",
          "shared_with_org_id",
          "shared_with_org_name",
          "permission",
          "status",
          "created_by",
          "created_at",
          "accepted_at",
          "revoked_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "project_id": {
            "type": "string",
            "format": "uuid"
          },
          "owner_org_id": {
            "type": "string",
            "format": "uuid"
          },
          "shared_with_org_id": {
            "type": "string",
            "format": "uuid"
          },
          "shared_with_org_name": {
            "type": "string"
          },
          "permission": {
            "type": "string",
            "enum": [
              "read",
              "read_comment",
              "full"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "revoked",
              "expired"
            ],
            "description": "Estado actual de la compartición."
          },
          "created_by": {
            "type": "string",
            "format": "uuid"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "accepted_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Momento en que la org destino aceptó el acceso; `null` si aún no."
          },
          "revoked_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Momento en que se revocó el acceso; `null` si sigue activo."
          }
        }
      },
      "SharedProject": {
        "type": "object",
        "title": "SharedProject",
        "description": "Proyecto ajeno accesible por la organización actual gracias a un `ProjectShare` sobre un `OrgLink` aceptado. `permission` indica el acceso concedido.",
        "required": [
          "id",
          "name",
          "owner_org_id",
          "owner_org_name",
          "permission",
          "status"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "owner_org_id": {
            "type": "string",
            "format": "uuid"
          },
          "owner_org_name": {
            "type": "string"
          },
          "permission": {
            "type": "string",
            "description": "Acceso concedido, en tres niveles: `read` solo lectura, `read_comment` lectura y comentarios, `full` escritura de tareas. Los mismos tres que declaran `ProjectShare` (lado dueño) y `SharedProjectDetail` (el detalle de este mismo proyecto): esta lista se quedó en dos y la card del listado pintaba el chip de permiso VACÍO para las comparticiones `read_comment`, porque su mapa de etiquetas se deriva de este enum.",
            "enum": [
              "read",
              "read_comment",
              "full"
            ]
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Logo/avatar del proyecto ajeno; permite pintar la card igual que las propias. `null` si no tiene."
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "archived"
            ],
            "description": "Estado del proyecto ajeno (chip de estado en la card)."
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "description": "Descripción del proyecto ajeno; `null` si no tiene."
          }
        }
      },
      "SharedProjectDetail": {
        "type": "object",
        "title": "SharedProjectDetail",
        "description": "Detalle de lectura de un proyecto compartido por otra organización. Se sirve únicamente por la superficie cross-tenant de solo lectura; el acceso exige un `ProjectShare` sobre un `OrgLink` aceptado.",
        "required": [
          "id",
          "name",
          "description",
          "status",
          "owner_org_id",
          "permission",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "archived"
            ]
          },
          "owner_org_id": {
            "type": "string",
            "format": "uuid"
          },
          "permission": {
            "type": "string",
            "enum": [
              "read",
              "read_comment",
              "full"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ExternalCollaborator": {
        "type": "object",
        "title": "ExternalCollaborator",
        "description": "Usuario de una organización externa que tiene acceso colaborativo a un proyecto de esta organización. Requiere un `OrgLink` aceptado entre ambas.",
        "required": [
          "id",
          "project_id",
          "user_id",
          "user_org_id",
          "user_name",
          "role",
          "invited_by",
          "accepted_at",
          "revoked_at",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "project_id": {
            "type": "string",
            "format": "uuid"
          },
          "user_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del usuario invitado; `null` si el usuario fue eliminado."
          },
          "user_org_id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID de la organización del colaborador."
          },
          "user_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nombre del colaborador (resuelto); `null` si el usuario fue eliminado."
          },
          "role": {
            "type": "string",
            "enum": [
              "viewer",
              "commenter",
              "approver"
            ],
            "description": "Rol del colaborador en el proyecto."
          },
          "invited_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del usuario que invitó al colaborador; `null` si fue eliminado."
          },
          "accepted_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Momento en que el colaborador aceptó; `null` si pendiente."
          },
          "revoked_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Momento en que se revocó el acceso; `null` si sigue activo."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Department": {
        "type": "object",
        "title": "Department",
        "description": "Departamento de RRHH de una organización.",
        "required": [
          "id",
          "organization_id",
          "name",
          "acronym",
          "description",
          "logo_url",
          "color",
          "weekly_schedule",
          "annual_vacation_days",
          "holidays",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "a1b2c3d4-e5f6-7a8b-9c0d-e1f2a3b4c5d6"
            ]
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          },
          "name": {
            "type": "string",
            "maxLength": 120,
            "examples": [
              "Ingeniería"
            ]
          },
          "acronym": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 8,
            "description": "Acrónimo corto del departamento para vistas compactas (organigrama, chips, listados). Se guarda SIEMPRE en MAYÚSCULAS. Único por organización cuando no es `null`. `null` si no se ha indicado.",
            "pattern": "^[A-Z0-9&-]{1,8}$",
            "examples": [
              "ING"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 500,
            "description": "Descripción del ámbito del departamento; `null` si no se ha indicado.",
            "examples": [
              "Desarrollo de producto, plataforma e infraestructura."
            ]
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL del logo del departamento; `null` si no se ha indicado.",
            "examples": [
              "https://cdn.example.com/logos/ing.png"
            ]
          },
          "color": {
            "type": [
              "string",
              "null"
            ],
            "description": "Color hex del departamento en formato `#RRGGBB`; `null` si no se ha indicado.",
            "pattern": "^#[0-9A-Fa-f]{6}$",
            "examples": [
              "#FD2554"
            ]
          },
          "weekly_schedule": {
            "type": [
              "object",
              "null"
            ],
            "description": "Horario laboral semanal. Cada clave es un día de la semana (`mon`–`sun`). El valor es `null` (no laboral) o un objeto `{start: \"HH:MM\", end: \"HH:MM\"}`.",
            "properties": {
              "mon": {
                "$ref": "#/components/schemas/DaySchedule"
              },
              "tue": {
                "$ref": "#/components/schemas/DaySchedule"
              },
              "wed": {
                "$ref": "#/components/schemas/DaySchedule"
              },
              "thu": {
                "$ref": "#/components/schemas/DaySchedule"
              },
              "fri": {
                "$ref": "#/components/schemas/DaySchedule"
              },
              "sat": {
                "$ref": "#/components/schemas/DaySchedule"
              },
              "sun": {
                "$ref": "#/components/schemas/DaySchedule"
              }
            },
            "definitions": {
              "DaySchedule": {
                "oneOf": [
                  {
                    "type": "null"
                  },
                  {
                    "type": "object",
                    "required": [
                      "start",
                      "end"
                    ],
                    "properties": {
                      "start": {
                        "type": "string",
                        "pattern": "^\\d{2}:\\d{2}$",
                        "examples": [
                          "09:00"
                        ]
                      },
                      "end": {
                        "type": "string",
                        "pattern": "^\\d{2}:\\d{2}$",
                        "examples": [
                          "17:00"
                        ]
                      }
                    }
                  }
                ]
              }
            }
          },
          "annual_vacation_days": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 40,
            "description": "Días de vacaciones anuales del departamento; `null` si no se ha indicado.",
            "examples": [
              22
            ]
          },
          "holidays": {
            "type": [
              "array",
              "null"
            ],
            "description": "Lista de festivos propios del departamento. `null` si no se han definido.",
            "items": {
              "type": "object",
              "required": [
                "date",
                "name"
              ],
              "properties": {
                "date": {
                  "type": "string",
                  "format": "date",
                  "examples": [
                    "2026-12-25"
                  ]
                },
                "name": {
                  "type": "string",
                  "examples": [
                    "Navidad"
                  ]
                }
              }
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-13T10:00:00Z"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-13T10:15:00Z"
            ]
          }
        }
      },
      "DepartmentCreateIn": {
        "type": "object",
        "title": "DepartmentCreateIn",
        "description": "Cuerpo de creación de un departamento.",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 120,
            "description": "Nombre del departamento. Único por organización (case-insensitive).",
            "examples": [
              "Ingeniería"
            ]
          },
          "acronym": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 8,
            "description": "Acrónimo corto (guía: 2–6 caracteres) para vistas compactas. Se normaliza a MAYÚSCULAS; solo letras, números, `&` y `-`. Único por organización cuando no es `null` (`409 acronym_taken` si ya existe). Cadena vacía = `null`.",
            "examples": [
              "ING"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 500,
            "description": "Descripción del ámbito del departamento. Cadena vacía = `null`.",
            "examples": [
              "Desarrollo de producto, plataforma e infraestructura."
            ]
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL del logo del departamento.",
            "examples": [
              "https://cdn.example.com/logos/ing.png"
            ]
          },
          "color": {
            "type": [
              "string",
              "null"
            ],
            "description": "Color hex en formato `#RRGGBB`.",
            "pattern": "^#[0-9A-Fa-f]{6}$",
            "examples": [
              "#FD2554"
            ]
          },
          "weekly_schedule": {
            "type": [
              "object",
              "null"
            ],
            "description": "Horario semanal. Claves `mon`–`sun`; valor `null` (no laboral) o `{start: \"HH:MM\", end: \"HH:MM\"}`."
          },
          "annual_vacation_days": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 40,
            "description": "Días de vacaciones anuales del departamento.",
            "examples": [
              22
            ]
          },
          "holidays": {
            "type": [
              "array",
              "null"
            ],
            "description": "Festivos propios del departamento.",
            "items": {
              "type": "object",
              "required": [
                "date",
                "name"
              ],
              "properties": {
                "date": {
                  "type": "string",
                  "format": "date",
                  "examples": [
                    "2026-12-25"
                  ]
                },
                "name": {
                  "type": "string",
                  "examples": [
                    "Navidad"
                  ]
                }
              }
            }
          }
        }
      },
      "DepartmentUpdateIn": {
        "type": "object",
        "title": "DepartmentUpdateIn",
        "description": "Actualización parcial de un departamento (PATCH; todos los campos opcionales).",
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 120,
            "description": "Nuevo nombre. Si cambia, sincroniza `employees.department` de los vinculados.",
            "examples": [
              "I+D"
            ]
          },
          "acronym": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 8,
            "description": "Nuevo acrónimo (guía: 2–6 caracteres); `null` o cadena vacía lo borra. Se normaliza a MAYÚSCULAS; solo letras, números, `&` y `-`. Único por organización cuando no es `null` (`409 acronym_taken`).",
            "examples": [
              "I+D"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 500,
            "description": "Nueva descripción; `null` o cadena vacía la borra."
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nueva URL del logo; `null` borra el logo."
          },
          "color": {
            "type": [
              "string",
              "null"
            ],
            "description": "Color hex en formato `#RRGGBB`; `null` borra el color.",
            "pattern": "^#[0-9A-Fa-f]{6}$"
          },
          "weekly_schedule": {
            "type": [
              "object",
              "null"
            ],
            "description": "Nuevo horario semanal; `null` borra el horario."
          },
          "annual_vacation_days": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 40,
            "description": "Días de vacaciones; `null` borra el valor."
          },
          "holidays": {
            "type": [
              "array",
              "null"
            ],
            "description": "Festivos; `null` borra la lista.",
            "items": {
              "type": "object",
              "required": [
                "date",
                "name"
              ],
              "properties": {
                "date": {
                  "type": "string",
                  "format": "date"
                },
                "name": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "Employee": {
        "type": "object",
        "title": "Employee",
        "description": "Empleado del directorio de RRHH registrado por una organización.",
        "required": [
          "id",
          "organization_id",
          "first_name",
          "last_name",
          "email",
          "position",
          "department",
          "employment_type",
          "base_salary",
          "currency",
          "hire_date",
          "tax_id_type",
          "tax_id",
          "social_security_number",
          "birth_date",
          "nationality",
          "phone",
          "address",
          "city",
          "province",
          "postal_code",
          "country",
          "tax_country",
          "bank_iban",
          "bank_routing_number",
          "bank_account_number",
          "gross_annual_salary",
          "irpf_rate",
          "pagas",
          "is_active",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "7f1c2d34-5678-4abc-9def-0123456789ab"
            ]
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          },
          "first_name": {
            "type": "string",
            "examples": [
              "Ana"
            ]
          },
          "last_name": {
            "type": "string",
            "examples": [
              "García"
            ]
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "description": "Email de contacto; `null` si no se ha indicado.",
            "examples": [
              "ana.garcia@acme.com"
            ]
          },
          "position": {
            "type": [
              "string",
              "null"
            ],
            "description": "Puesto / cargo; `null` si no se ha indicado.",
            "examples": [
              "Backend Engineer"
            ]
          },
          "department": {
            "type": [
              "string",
              "null"
            ],
            "description": "Departamento; `null` si no se ha indicado.",
            "examples": [
              "Engineering"
            ]
          },
          "department_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del departamento estructurado vinculado a la ficha; `null` si no está vinculada. Es el id que acepta el PATCH del empleado: sin declararlo aquí, quien lo escribía no podía leer lo que había escrito.",
            "examples": [
              "7f1c2d34-5678-4abc-9def-0123456789ab"
            ]
          },
          "employment_type": {
            "type": "string",
            "description": "Tipo de relación laboral. Conjunto ABIERTO (ver la nota de cabecera): hoy `interno`, `autonomo` y `externo` (España) y `w2` y `1099` (EE. UU.), y crecerá con cada jurisdicción. Un cliente debe tratar un valor que no reconozca como texto opaco, no como un error.",
            "default": "interno",
            "x-known-values": [
              "interno",
              "autonomo",
              "externo",
              "w2",
              "1099"
            ],
            "examples": [
              "interno",
              "w2"
            ]
          },
          "base_salary": {
            "type": [
              "number",
              "null"
            ],
            "description": "Salario base bruto; `null` si no se ha indicado.",
            "examples": [
              42000
            ]
          },
          "currency": {
            "type": "string",
            "description": "Código ISO-4217 de 3 letras.",
            "default": "EUR",
            "examples": [
              "EUR"
            ]
          },
          "hire_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha de alta; `null` si no se ha indicado.",
            "examples": [
              "2024-01-15"
            ]
          },
          "tax_id_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "Tipo de identificación fiscal; `null` si no se ha indicado. Conjunto ABIERTO (ver la nota de cabecera): hoy `DNI`, `NIE` y `CIF` (España) y `SSN`, `EIN` e `ITIN` (EE. UU.). El tipo es autodescriptivo y NO está limitado por `tax_country`: una empresa española puede tener en plantilla a alguien con ITIN.",
            "x-known-values": [
              "DNI",
              "NIE",
              "CIF",
              "SSN",
              "EIN",
              "ITIN"
            ],
            "examples": [
              "DNI",
              "SSN"
            ]
          },
          "tax_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Identificación fiscal validada contra `tax_id_type` (DNI/NIE/CIF con su dígito de control; SSN/EIN/ITIN con los rangos que emiten la SSA y el IRS); `null` si no se ha indicado. PII.",
            "examples": [
              "12345678Z"
            ]
          },
          "social_security_number": {
            "type": [
              "string",
              "null"
            ],
            "description": "Número laboral del empleado en su jurisdicción, validado según `tax_country`: NUSS de 11-12 dígitos en España, SSN de 9 dígitos en EE. UU. y solo comprobación de forma en un país sin regla conocida. `null` si no se ha indicado. PII.",
            "examples": [
              "281234567840",
              "123456789"
            ]
          },
          "birth_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha de nacimiento; `null` si no se ha indicado.",
            "examples": [
              "1990-05-20"
            ]
          },
          "nationality": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nacionalidad; `null` si no se ha indicado.",
            "examples": [
              "Española"
            ]
          },
          "phone": {
            "type": [
              "string",
              "null"
            ],
            "description": "Teléfono de contacto; `null` si no se ha indicado.",
            "examples": [
              "+34 600 111 222"
            ]
          },
          "address": {
            "type": [
              "string",
              "null"
            ],
            "description": "Dirección postal; `null` si no se ha indicado.",
            "examples": [
              "Calle Mayor 1"
            ]
          },
          "city": {
            "type": [
              "string",
              "null"
            ],
            "description": "Ciudad; `null` si no se ha indicado.",
            "examples": [
              "Madrid"
            ]
          },
          "province": {
            "type": [
              "string",
              "null"
            ],
            "description": "Provincia; `null` si no se ha indicado.",
            "examples": [
              "Madrid"
            ]
          },
          "postal_code": {
            "type": [
              "string",
              "null"
            ],
            "description": "Código postal; `null` si no se ha indicado.",
            "examples": [
              "28013"
            ]
          },
          "country": {
            "type": [
              "string",
              "null"
            ],
            "description": "País; `null` si no se ha indicado.",
            "examples": [
              "España"
            ]
          },
          "tax_country": {
            "type": "string",
            "minLength": 2,
            "maxLength": 2,
            "description": "Jurisdicción laboral y fiscal del empleado (ISO 3166-1 alfa-2). Decide con qué regla se leen `social_security_number` y `pagas`. NO es el `country` de la dirección postal, que es texto libre. **Nunca `null`**: si la ficha no lo fija, el servidor devuelve aquí el país de la organización ya resuelto, para que el cliente no tenga que repetir esa resolución ni acertar con el mismo respaldo.",
            "default": "ES",
            "examples": [
              "ES",
              "US"
            ]
          },
          "bank_iban": {
            "type": [
              "string",
              "null"
            ],
            "description": "IBAN de la cuenta de nómina (validado mod-97); `null` si no se ha indicado. PII.",
            "examples": [
              "ES9121000418450200051332"
            ]
          },
          "bank_routing_number": {
            "type": [
              "string",
              "null"
            ],
            "description": "Routing number ABA (EE. UU.), 9 dígitos con checksum 3-7-1; `null` si no se ha indicado. Va SIEMPRE acompañado de `bank_account_number`. PII.",
            "examples": [
              "021000021"
            ]
          },
          "bank_account_number": {
            "type": [
              "string",
              "null"
            ],
            "description": "Número de cuenta no-IBAN (EE. UU. y otros países sin IBAN); `null` si no se ha indicado. Va SIEMPRE acompañado de `bank_routing_number`. PII.",
            "examples": [
              "000123456789"
            ]
          },
          "gross_annual_salary": {
            "type": [
              "number",
              "null"
            ],
            "description": "Salario bruto anual (Wave F); `null` si no se ha configurado.",
            "examples": [
              30000
            ]
          },
          "irpf_rate": {
            "type": [
              "number",
              "null"
            ],
            "description": "Tipo de retención IRPF en porcentaje (0–100); `null` si no se ha configurado.",
            "examples": [
              15
            ]
          },
          "pagas": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Número de pagas anuales, admisible según `tax_country`: 12 o 14 en España; 12, 24, 26 o 52 en EE. UU.; entre 1 y 53 en un país sin tabla conocida. `null` = no se sabe. Omitir el campo al crear aplica el valor normal del país (12 en España; fuera de España NO se inventa uno y queda `null`).",
            "examples": [
              12,
              26
            ]
          },
          "manager_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del empleado manager directo; `null` si no tiene manager asignado.",
            "examples": [
              "a1b2c3d4-5678-4abc-9def-0123456789ef"
            ]
          },
          "is_active": {
            "type": "boolean",
            "description": "Empleado activo.",
            "default": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-09T14:00:00Z"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-09T14:15:00Z"
            ]
          }
        }
      },
      "EmployeeDepartmentStat": {
        "type": "object",
        "title": "EmployeeDepartmentStat",
        "description": "Agregado de empleados por departamento (para gráfico de plantilla).",
        "required": [
          "department",
          "count"
        ],
        "properties": {
          "department": {
            "type": [
              "string",
              "null"
            ],
            "description": "Departamento; `null` agrupa a los empleados sin departamento asignado.",
            "examples": [
              "Engineering"
            ]
          },
          "count": {
            "type": "integer",
            "description": "Número de empleados del departamento.",
            "examples": [
              4
            ]
          }
        }
      },
      "OrgChartNode": {
        "type": "object",
        "title": "OrgChartNode",
        "description": "Empleado en el organigrama jerárquico. `children` contiene los reportes directos del nodo (recursivo). Los nodos raíz son los empleados sin manager o cuyo manager_id no existe en la organización.",
        "required": [
          "employee_id",
          "name",
          "is_active",
          "children"
        ],
        "properties": {
          "employee_id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID de la ficha employee.",
            "examples": [
              "7f1c2d34-5678-4abc-9def-0123456789ab"
            ]
          },
          "user_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del usuario Projekt vinculado; `null` si la ficha no tiene cuenta.",
            "examples": [
              "6f1f9c3e-8f2a-4b6e-9a3d-2b1c0d4e5f60"
            ]
          },
          "name": {
            "type": "string",
            "description": "Nombre completo del empleado.",
            "examples": [
              "Ana García"
            ]
          },
          "position": {
            "type": [
              "string",
              "null"
            ],
            "description": "Cargo/puesto; `null` si no se ha indicado.",
            "examples": [
              "Backend Engineer"
            ]
          },
          "department": {
            "type": [
              "string",
              "null"
            ],
            "description": "Departamento; `null` si no se ha indicado.",
            "examples": [
              "Engineering"
            ]
          },
          "avatar_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL del avatar del usuario vinculado; `null` si no tiene.",
            "examples": [
              "https://cdn.projekt.3xa.es/avatars/ana.png"
            ]
          },
          "is_active": {
            "type": "boolean",
            "description": "Empleado activo.",
            "default": true
          },
          "children": {
            "type": "array",
            "description": "Reportes directos de este nodo (árbol recursivo).",
            "items": {
              "$ref": "#/components/schemas/OrgChartNode"
            }
          }
        }
      },
      "TimeEntry": {
        "type": "object",
        "title": "TimeEntry",
        "description": "Registro de tiempo imputado por un usuario en una organización.",
        "required": [
          "id",
          "organization_id",
          "user_id",
          "project_id",
          "project_name",
          "task_id",
          "task_reference",
          "task_title",
          "description",
          "minutes",
          "entry_date",
          "is_billable",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "9c8b7a65-4321-4fed-cba9-876543210fed"
            ]
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          },
          "user_id": {
            "type": "string",
            "format": "uuid",
            "description": "Autor del registro (usuario autenticado que lo creó).",
            "examples": [
              "6f1f9c3e-8f2a-4b6e-9a3d-2b1c0d4e5f60"
            ]
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Proyecto imputado; `null` si no se ha asociado."
          },
          "project_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nombre del proyecto imputado, resuelto por el servidor (join a projects, un único query por página — no N+1). `null` si el registro no tiene proyecto. Un proyecto ARCHIVADO sigue devolviendo su nombre: sus horas son reales y el cliente no tiene por qué enseñarlas como «desconocido».",
            "examples": [
              "Rediseño de la web"
            ]
          },
          "task_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Tarea imputada; `null` si no se ha asociado."
          },
          "task_reference": {
            "type": [
              "string",
              "null"
            ],
            "description": "Referencia legible de la tarea imputada, formato `{PROJECT_KEY}-{number}` (p.ej. `PJKT-1933`). Resuelta por el servidor (join a la tarea/proyecto). `null` si el registro no referencia una tarea (o la tarea ya no existe).",
            "examples": [
              "PJKT-1933"
            ]
          },
          "task_title": {
            "type": [
              "string",
              "null"
            ],
            "description": "Título de la tarea imputada; `null` si el registro no referencia una tarea (o la tarea ya no existe).",
            "examples": [
              "Maquetación de la home"
            ]
          },
          "invoice_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Factura que consumió este registro (timesheet→factura); `null` si el tiempo aún no se ha facturado."
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "description": "Descripción libre del trabajo; `null` si no se ha indicado.",
            "examples": [
              "Maquetación de la home"
            ]
          },
          "minutes": {
            "type": "integer",
            "minimum": 1,
            "description": "Duración imputada en minutos (siempre mayor que 0).",
            "examples": [
              90
            ]
          },
          "entry_date": {
            "type": "string",
            "format": "date",
            "description": "Fecha a la que se imputa el tiempo.",
            "examples": [
              "2026-07-08"
            ]
          },
          "is_billable": {
            "type": "boolean",
            "description": "Si el tiempo es facturable al cliente (rentabilidad, mig 0062). `true` por defecto.",
            "default": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-08T14:00:00Z"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-08T14:15:00Z"
            ]
          }
        }
      },
      "TimeEntryStartIn": {
        "type": "object",
        "title": "TimeEntryStartIn",
        "description": "Arranca un timer en vivo. El autor (`user_id`) es siempre el usuario autenticado; `entry_date`/`started_at` los fija el servidor.",
        "properties": {
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Opcional; proyecto imputado."
          },
          "task_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Opcional; tarea imputada."
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "description": "Opcional; puede omitirse o enviarse `null`."
          },
          "is_billable": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Opcional; si el tiempo es facturable al cliente. Default `true` si se omite — y `null` es lo mismo que omitirlo (el servidor resuelve `is_billable if ... is not None else True`), así que el contrato lo declara en vez de prohibir un cuerpo que el API acepta.",
            "default": true
          }
        }
      },
      "TimeEntryRunning": {
        "type": "object",
        "title": "TimeEntryRunning",
        "description": "Timer de tiempo en curso (aún sin parar). Un usuario tiene como mucho uno por organización.",
        "required": [
          "id",
          "organization_id",
          "user_id",
          "project_id",
          "project_name",
          "task_id",
          "task_reference",
          "task_title",
          "description",
          "entry_date",
          "started_at",
          "is_billable",
          "elapsed_seconds"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "user_id": {
            "type": "string",
            "format": "uuid",
            "description": "Autor del timer (usuario autenticado que lo arrancó)."
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Proyecto imputado; `null` si no se ha asociado."
          },
          "project_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nombre del proyecto imputado, resuelto por el servidor (join a projects, un único query por página — no N+1). `null` si el registro no tiene proyecto. Un proyecto ARCHIVADO sigue devolviendo su nombre: sus horas son reales y el cliente no tiene por qué enseñarlas como «desconocido».",
            "examples": [
              "Rediseño de la web"
            ]
          },
          "task_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Tarea imputada; `null` si no se ha asociado."
          },
          "task_reference": {
            "type": [
              "string",
              "null"
            ],
            "description": "Referencia legible de la tarea imputada, formato `{PROJECT_KEY}-{number}`. `null` si no referencia una tarea.",
            "examples": [
              "PJKT-1933"
            ]
          },
          "task_title": {
            "type": [
              "string",
              "null"
            ],
            "description": "Título de la tarea imputada; `null` si no referencia una tarea."
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "description": "Descripción libre del trabajo; `null` si no se ha indicado."
          },
          "entry_date": {
            "type": "string",
            "format": "date",
            "description": "Fecha (UTC) a la que se imputará el tiempo una vez parado."
          },
          "started_at": {
            "type": "string",
            "format": "date-time",
            "description": "Instante UTC en que arrancó el timer."
          },
          "is_billable": {
            "type": "boolean",
            "description": "Si el tiempo será facturable al cliente una vez parado."
          },
          "elapsed_seconds": {
            "type": "integer",
            "minimum": 0,
            "description": "Segundos transcurridos desde `started_at` hasta AHORA (en vivo, recalculado en cada respuesta).",
            "examples": [
              754
            ]
          }
        }
      },
      "ActiveTimerOut": {
        "type": "object",
        "title": "ActiveTimerOut",
        "description": "Timer en curso del usuario actual, o ninguno.",
        "required": [
          "is_running",
          "timer"
        ],
        "properties": {
          "is_running": {
            "type": "boolean",
            "description": "Si el usuario tiene un timer corriendo ahora mismo."
          },
          "timer": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/TimeEntryRunning"
              },
              {
                "type": "null"
              }
            ],
            "description": "El timer en curso; `null` si `is_running` es `false`."
          }
        }
      },
      "JornadaAsiento": {
        "type": "object",
        "title": "JornadaAsiento",
        "description": "Un asiento del registro de jornada de una persona. Los asientos no se editan ni se borran: para rectificar uno se crea otro con `corrige_a` y `motivo`, y los dos quedan.",
        "required": [
          "id",
          "organization_id",
          "user_id",
          "tipo",
          "ocurrido_en",
          "registrado_en",
          "origen",
          "numero",
          "huella",
          "huella_anterior",
          "corrige_a",
          "motivo",
          "autor_id"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "user_id": {
            "type": "string",
            "format": "uuid",
            "description": "La persona cuya jornada se registra."
          },
          "tipo": {
            "type": "string",
            "enum": [
              "entrada",
              "salida",
              "pausa_inicio",
              "pausa_fin"
            ],
            "description": "Qué se registra."
          },
          "ocurrido_en": {
            "type": "string",
            "format": "date-time",
            "description": "Instante en que ocurrió, en UTC. Normalmente es el de registro; en una corrección es el instante real que se rectifica."
          },
          "registrado_en": {
            "type": "string",
            "format": "date-time",
            "description": "Instante en que el servidor escribió la fila, en UTC. Lo pone el reloj del servidor y no lo puede aportar el cliente: es lo que hace comparable «cuándo pasó» con «cuándo se dijo que pasó»."
          },
          "origen": {
            "type": "string",
            "enum": [
              "web",
              "movil",
              "kiosco",
              "api",
              "correccion"
            ],
            "description": "Por dónde entró el asiento."
          },
          "numero": {
            "type": "integer",
            "minimum": 1,
            "description": "Posición del asiento en la cadena de ESA persona en ESTA organización, empezando en 1. Un hueco en la numeración es visible."
          },
          "huella": {
            "type": "string",
            "description": "SHA-256 en hexadecimal de la huella anterior encadenada con el contenido de este asiento. Cambiar una fila cambia su huella y rompe la cadena de todas las siguientes."
          },
          "huella_anterior": {
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "description": "Huella del asiento anterior de esta persona; `null` en el primero."
          },
          "corrige_a": {
            "oneOf": [
              {
                "type": "string",
                "format": "uuid"
              },
              {
                "type": "null"
              }
            ],
            "description": "Asiento que este rectifica; `null` si no rectifica ninguno."
          },
          "motivo": {
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "description": "Por qué se rectifica. Obligatorio si `corrige_a` no es nulo."
          },
          "autor_id": {
            "type": "string",
            "format": "uuid",
            "description": "Quién escribió el asiento. Coincide con `user_id` salvo cuando lo registra un responsable por otra persona."
          }
        }
      },
      "JornadaAsientoCreate": {
        "type": "object",
        "title": "JornadaAsientoCreate",
        "description": "Datos para registrar un asiento de jornada.",
        "required": [
          "tipo"
        ],
        "properties": {
          "tipo": {
            "type": "string",
            "enum": [
              "entrada",
              "salida",
              "pausa_inicio",
              "pausa_fin"
            ]
          },
          "user_id": {
            "oneOf": [
              {
                "type": "string",
                "format": "uuid"
              },
              {
                "type": "null"
              }
            ],
            "description": "Persona cuya jornada se registra. Por defecto, quien llama. Registrar por otra persona exige rol `manager` o superior Y `motivo`."
          },
          "ocurrido_en": {
            "oneOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ],
            "description": "Instante real, en UTC. Por defecto, ahora. Un instante distinto de ahora exige `motivo`: fichar «a las nueve» a las once es una rectificación, y se registra como tal."
          },
          "origen": {
            "type": "string",
            "enum": [
              "web",
              "movil",
              "kiosco",
              "api"
            ],
            "default": "web"
          },
          "corrige_a": {
            "oneOf": [
              {
                "type": "string",
                "format": "uuid"
              },
              {
                "type": "null"
              }
            ],
            "description": "Asiento que se rectifica. Exige `motivo`."
          },
          "motivo": {
            "oneOf": [
              {
                "type": "string",
                "maxLength": 500
              },
              {
                "type": "null"
              }
            ],
            "description": "Por qué. Obligatorio al rectificar, al registrar por otra persona o al aportar un `ocurrido_en` distinto de ahora."
          }
        }
      },
      "JornadaDia": {
        "type": "object",
        "title": "JornadaDia",
        "description": "Estado de la jornada de hoy de una persona.",
        "required": [
          "fecha",
          "dentro",
          "en_pausa",
          "minutos_trabajados",
          "minutos_en_pausa",
          "asientos"
        ],
        "properties": {
          "fecha": {
            "type": "string",
            "format": "date",
            "description": "El día al que se refiere, en la zona horaria de la organización."
          },
          "dentro": {
            "type": "boolean",
            "description": "Si la jornada está abierta (hubo entrada y no ha habido salida)."
          },
          "en_pausa": {
            "type": "boolean",
            "description": "Si hay una pausa abierta."
          },
          "minutos_trabajados": {
            "type": "integer",
            "minimum": 0,
            "description": "Minutos de jornada cerrados hasta ahora, descontando las pausas. Si la jornada sigue abierta, cuenta hasta este instante."
          },
          "minutos_en_pausa": {
            "type": "integer",
            "minimum": 0
          },
          "asientos": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/JornadaAsiento"
            },
            "description": "Los asientos del día, del más antiguo al más reciente."
          }
        }
      },
      "JornadaFalloDeCadena": {
        "type": "object",
        "title": "JornadaFalloDeCadena",
        "description": "Asiento donde la cadena de huellas deja de cuadrar.",
        "required": [
          "asiento_id",
          "user_id",
          "numero",
          "motivo"
        ],
        "properties": {
          "asiento_id": {
            "type": "string",
            "format": "uuid"
          },
          "user_id": {
            "type": "string",
            "format": "uuid"
          },
          "numero": {
            "type": "integer"
          },
          "motivo": {
            "type": "string",
            "description": "Qué no cuadra (huella distinta, hueco en la numeración, cadena rota)."
          }
        }
      },
      "JornadaVerificacion": {
        "type": "object",
        "title": "JornadaVerificacion",
        "description": "Resultado de recalcular la cadena de huellas del registro de jornada.",
        "required": [
          "intacta",
          "asientos_comprobados",
          "personas_comprobadas",
          "primer_fallo"
        ],
        "properties": {
          "intacta": {
            "type": "boolean",
            "description": "Si toda la cadena recalculada coincide con lo guardado."
          },
          "asientos_comprobados": {
            "type": "integer",
            "minimum": 0
          },
          "personas_comprobadas": {
            "type": "integer",
            "minimum": 0
          },
          "primer_fallo": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/JornadaFalloDeCadena"
              },
              {
                "type": "null"
              }
            ],
            "description": "El primer asiento donde la cadena deja de cuadrar; `null` si está intacta."
          }
        }
      },
      "VerifactuRegistro": {
        "type": "object",
        "title": "VerifactuRegistro",
        "description": "Un registro de facturación encadenado: el alta de una factura o su anulación. Los datos de la factura van COPIADOS y no referenciados, para que el registro siga diciendo lo mismo dentro de cuatro años.",
        "required": [
          "id",
          "organization_id",
          "invoice_id",
          "tipo",
          "nif_emisor",
          "numero_de_factura",
          "fecha_expedicion",
          "descripcion",
          "importe_total",
          "cuota_total",
          "numero",
          "huella",
          "huella_anterior",
          "generado_en",
          "estado_envio",
          "software_nombre",
          "software_id",
          "software_version"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "invoice_id": {
            "type": "string",
            "format": "uuid"
          },
          "tipo": {
            "type": "string",
            "enum": [
              "alta",
              "anulacion"
            ]
          },
          "nif_emisor": {
            "type": "string",
            "description": "NIF del obligado tributario cuando se emitió. `SIN-NIF` si la organización no tenía uno puesto."
          },
          "numero_de_factura": {
            "type": "string"
          },
          "fecha_expedicion": {
            "type": "string",
            "format": "date"
          },
          "descripcion": {
            "type": "string"
          },
          "importe_total": {
            "type": "string",
            "description": "Importe total, con dos decimales."
          },
          "cuota_total": {
            "type": "string",
            "description": "Cuota de IVA, con dos decimales."
          },
          "numero": {
            "type": "integer",
            "minimum": 1,
            "description": "Posición en la cadena de la organización, desde 1. Un hueco es visible."
          },
          "huella": {
            "type": "string",
            "description": "SHA-256 en hexadecimal de la huella anterior encadenada con este registro."
          },
          "huella_anterior": {
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "generado_en": {
            "type": "string",
            "format": "date-time"
          },
          "estado_envio": {
            "type": "string",
            "enum": [
              "no_enviado",
              "enviado",
              "rechazado"
            ],
            "description": "Hoy vale siempre `no_enviado`: el envío a la AEAT exige un certificado del obligado tributario, que es un trámite de la empresa y no algo que el software pueda hacer solo."
          },
          "software_nombre": {
            "type": "string"
          },
          "software_id": {
            "type": "string"
          },
          "software_version": {
            "type": "string"
          }
        }
      },
      "VerifactuFalloDeCadena": {
        "type": "object",
        "title": "VerifactuFalloDeCadena",
        "description": "Registro donde la cadena de huellas deja de cuadrar.",
        "required": [
          "registro_id",
          "numero",
          "motivo"
        ],
        "properties": {
          "registro_id": {
            "type": "string",
            "format": "uuid"
          },
          "numero": {
            "type": "integer"
          },
          "motivo": {
            "type": "string",
            "description": "Qué no cuadra (huella distinta, hueco en la numeración, cadena rota)."
          }
        }
      },
      "VerifactuVerificacion": {
        "type": "object",
        "title": "VerifactuVerificacion",
        "description": "Resultado de recalcular la cadena de huellas del registro de facturación.",
        "required": [
          "intacta",
          "registros_comprobados",
          "primer_fallo"
        ],
        "properties": {
          "intacta": {
            "type": "boolean"
          },
          "registros_comprobados": {
            "type": "integer",
            "minimum": 0
          },
          "primer_fallo": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/VerifactuFalloDeCadena"
              },
              {
                "type": "null"
              }
            ]
          }
        }
      },
      "VerifactuCotejo": {
        "type": "object",
        "title": "VerifactuCotejo",
        "description": "La URL de cotejo que va dentro del QR de la factura, y la leyenda que la acompaña. La URL se construye desde el REGISTRO y no desde la factura: si la factura se rectificó después, sus importes ya no son los que se registraron.",
        "required": [
          "url",
          "leyenda"
        ],
        "properties": {
          "url": {
            "type": "string",
            "description": "URL de la sede de la AEAT con los cuatro parámetros (`nif`, `numserie`, `fecha` en dd-mm-yyyy, `importe` con punto decimal). Apunta al entorno de PRUEBAS mientras el envío a Hacienda no esté hecho."
          },
          "leyenda": {
            "type": "string"
          }
        }
      },
      "TimeEntrySummaryGroup": {
        "type": "object",
        "title": "TimeEntrySummaryGroup",
        "description": "Totales de un grupo del desglose. Solo uno de `project_id`/`user_id` es no-nulo, según `group_by` de la petición (el otro siempre `null`).",
        "required": [
          "project_id",
          "user_id",
          "total_minutes",
          "billable_minutes",
          "non_billable_minutes",
          "entries_count"
        ],
        "properties": {
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Presente cuando `group_by=project` (`null` agrupa las horas SIN proyecto); siempre `null` con otro `group_by`."
          },
          "user_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Presente cuando `group_by=user`; siempre `null` con otro `group_by`."
          },
          "total_minutes": {
            "type": "integer",
            "minimum": 0,
            "description": "Suma de minutos del grupo (entradas completadas; excluye timers en curso)."
          },
          "billable_minutes": {
            "type": "integer",
            "minimum": 0,
            "description": "Subtotal de `total_minutes` con `is_billable=true`."
          },
          "non_billable_minutes": {
            "type": "integer",
            "minimum": 0,
            "description": "`total_minutes - billable_minutes`."
          },
          "entries_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Número de registros completados que componen el grupo."
          }
        }
      },
      "TimeEntrySummary": {
        "type": "object",
        "title": "TimeEntrySummary",
        "description": "Agregado de horas imputadas de la organización, con desglose opcional por proyecto o autor. Los timers EN CURSO (mig 0107) nunca se cuentan: no tienen duración todavía.",
        "required": [
          "group_by",
          "from_date",
          "to_date",
          "total_minutes",
          "billable_minutes",
          "non_billable_minutes",
          "entries_count",
          "groups"
        ],
        "properties": {
          "group_by": {
            "type": "string",
            "enum": [
              "project",
              "user",
              "none"
            ],
            "description": "Dimensión de desglose solicitada (eco de la query `group_by`)."
          },
          "from_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Eco del filtro `from` (rango inclusive); `null` si no se filtró."
          },
          "to_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Eco del filtro `to` (rango inclusive); `null` si no se filtró."
          },
          "total_minutes": {
            "type": "integer",
            "minimum": 0,
            "description": "Gran total de minutos de TODAS las entradas que cumplen los filtros."
          },
          "billable_minutes": {
            "type": "integer",
            "minimum": 0,
            "description": "Subtotal de `total_minutes` con `is_billable=true`."
          },
          "non_billable_minutes": {
            "type": "integer",
            "minimum": 0,
            "description": "`total_minutes - billable_minutes`."
          },
          "entries_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Número total de registros completados que cumplen los filtros."
          },
          "groups": {
            "type": "array",
            "description": "Desglose por proyecto/autor; vacío si `group_by=none`.",
            "items": {
              "$ref": "#/components/schemas/TimeEntrySummaryGroup"
            }
          }
        }
      },
      "Document": {
        "type": "object",
        "title": "Document",
        "description": "Documento de una organización. `kind` discrimina la página markdown (`page`, contenido en `content`) del fichero subido (`file`, metadatos en `file_*` y bytes accesibles por `file_url`).",
        "required": [
          "id",
          "organization_id",
          "project_id",
          "folder_id",
          "parent_id",
          "position",
          "title",
          "content",
          "kind",
          "is_template",
          "file_name",
          "file_mime_type",
          "file_size",
          "file_url",
          "created_by",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "9c8b7a65-4321-4fed-cba9-876543210fed"
            ]
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Proyecto asociado; `null` si es un documento suelto."
          },
          "folder_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Carpeta donde está archivado; `null` si está suelto. Son las MISMAS carpetas de organización que agrupan proyectos (`/project-folders`), así que su visibilidad por departamento también se aplica aquí: un documento en una carpeta restringida no se lista ni se abre para quien no pertenece a ninguno de sus departamentos."
          },
          "source_quote_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Presupuesto del que este documento es el PDF archivado (`null` en el resto de documentos). Lo fija el servidor al archivar un presupuesto aceptado.",
            "examples": [
              "7a1b2c3d-4e5f-4061-8273-8495a6b7c8d9"
            ]
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Documento padre en el árbol jerárquico; `null` si está en la raíz. El cliente reconstruye el árbol a partir de este campo."
          },
          "position": {
            "type": "integer",
            "description": "Orden del documento entre sus hermanos (menor primero).",
            "examples": [
              0
            ]
          },
          "title": {
            "type": "string",
            "examples": [
              "Manual de onboarding"
            ]
          },
          "content": {
            "type": [
              "string",
              "null"
            ],
            "description": "Contenido markdown libre; `null` si el documento está vacío y SIEMPRE `null` cuando `kind` es `file`.",
            "examples": [
              "# Bienvenida\n\nPasos para empezar."
            ]
          },
          "kind": {
            "type": "string",
            "enum": [
              "page",
              "file"
            ],
            "description": "`page` = documento redactado en la app (markdown en `content`). `file` = fichero subido; sus bytes viven en el almacenamiento y se obtienen por `file_url`. Todos los documentos anteriores a esta función son `page`.",
            "examples": [
              "page"
            ]
          },
          "is_template": {
            "type": "boolean",
            "description": "`true` si el documento es una PLANTILLA de la organización: aparece en «nuevo documento desde plantilla» y se puede copiar. Marcarlo y retirarlo requiere admin+ (es mobiliario de la organización, no del autor); usarlo, cualquiera que pueda crear documentos. Una plantilla sigue siendo un documento normal: vive en su carpeta, se edita y se borra igual.",
            "default": false,
            "examples": [
              false
            ]
          },
          "file_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nombre original del fichero, ya saneado (solo el nombre, nunca una ruta). `null` en las páginas.",
            "examples": [
              "contrato-2026.pdf"
            ]
          },
          "file_mime_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "Tipo MIME deducido de la extensión admitida, NUNCA el que declaró el cliente. `null` en las páginas.",
            "examples": [
              "application/pdf"
            ]
          },
          "file_size": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Tamaño del fichero en bytes; `null` en las páginas.",
            "examples": [
              348122
            ]
          },
          "file_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Ruta del endpoint de descarga AUTENTICADO (no una ruta pública ni una URL firmada): cada descarga vuelve a comprobar la pertenencia a la organización y la visibilidad de la carpeta. `null` en las páginas.",
            "examples": [
              "/api/v1/organizations/0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162/documents/9c8b7a65-4321-4fed-cba9-876543210fed/download"
            ]
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Autor del documento; `null` si el usuario ya no existe.",
            "examples": [
              "6f1f9c3e-8f2a-4b6e-9a3d-2b1c0d4e5f60"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-08T14:00:00Z"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-08T14:15:00Z"
            ]
          }
        }
      },
      "DocumentVersion": {
        "type": "object",
        "title": "DocumentVersion",
        "description": "Snapshot de un estado previo de un documento (entrada del historial).",
        "required": [
          "id",
          "document_id",
          "title",
          "content_length",
          "content_preview",
          "created_by",
          "author_name",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "2c3d4e5f-6071-4829-3a4b-5c6d7e8f9012"
            ]
          },
          "document_id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "9c8b7a65-4321-4fed-cba9-876543210fed"
            ]
          },
          "title": {
            "type": "string",
            "description": "Título del documento en el momento del snapshot.",
            "examples": [
              "Manual de onboarding"
            ]
          },
          "content_length": {
            "type": "integer",
            "description": "Longitud (nº de caracteres) del contenido versionado; 0 si estaba vacío.",
            "examples": [
              128
            ]
          },
          "content_preview": {
            "type": [
              "string",
              "null"
            ],
            "description": "Primeros caracteres del contenido versionado; `null` si estaba vacío.",
            "examples": [
              "# Bienvenida\n\nPasos para empezar."
            ]
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Autor del snapshot; `null` si el usuario ya no existe.",
            "examples": [
              "6f1f9c3e-8f2a-4b6e-9a3d-2b1c0d4e5f60"
            ]
          },
          "author_name": {
            "type": "string",
            "description": "Nombre del autor del snapshot; `\"Sistema\"` si el autor fue borrado.",
            "examples": [
              "Nick Valdivia"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-09T12:00:00Z"
            ]
          }
        }
      },
      "DocumentVersionDetail": {
        "type": "object",
        "title": "DocumentVersionDetail",
        "description": "Snapshot de un documento con el contenido markdown completo.",
        "required": [
          "id",
          "document_id",
          "title",
          "content",
          "created_by",
          "author_name",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "2c3d4e5f-6071-4829-3a4b-5c6d7e8f9012"
            ]
          },
          "document_id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "9c8b7a65-4321-4fed-cba9-876543210fed"
            ]
          },
          "title": {
            "type": "string",
            "examples": [
              "Manual de onboarding"
            ]
          },
          "content": {
            "type": [
              "string",
              "null"
            ],
            "description": "Contenido markdown completo del snapshot; `null` si estaba vacío.",
            "examples": [
              "# Bienvenida\n\nPasos para empezar."
            ]
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Autor del snapshot; `null` si el usuario ya no existe.",
            "examples": [
              "6f1f9c3e-8f2a-4b6e-9a3d-2b1c0d4e5f60"
            ]
          },
          "author_name": {
            "type": "string",
            "description": "Nombre del autor del snapshot; `\"Sistema\"` si el autor fue borrado.",
            "examples": [
              "Nick Valdivia"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-09T12:00:00Z"
            ]
          }
        }
      },
      "DocumentComment": {
        "type": "object",
        "title": "DocumentComment",
        "description": "Comentario perteneciente a un documento (hilos por `parent_id`).",
        "required": [
          "id",
          "document_id",
          "author_id",
          "author_name",
          "body",
          "parent_id",
          "anchor",
          "is_edited",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "1a2b3c4d-5e6f-4071-8283-949506172839"
            ]
          },
          "document_id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "9c8b7a65-4321-4fed-cba9-876543210fed"
            ]
          },
          "author_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del autor; `null` si el autor fue borrado.",
            "examples": [
              "6f1f9c3e-8f2a-4b6e-9a3d-2b1c0d4e5f60"
            ]
          },
          "author_name": {
            "type": "string",
            "description": "Nombre del autor; `\"Sistema\"` si el autor fue borrado.",
            "examples": [
              "Nick Valdivia"
            ]
          },
          "body": {
            "type": "string",
            "description": "Cuerpo del comentario (texto libre).",
            "examples": [
              "Revisado; falta cablear el endpoint."
            ]
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Comentario padre si es una respuesta; `null` si es de primer nivel.",
            "examples": [
              "1a2b3c4d-5e6f-4071-8283-949506172839"
            ]
          },
          "anchor": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/DocumentCommentAnchor"
              },
              {
                "type": "null"
              }
            ],
            "description": "Fragmento del documento sobre el que se comenta, o `null` si el comentario es sobre el documento entero (todos los anteriores a esta función lo son). Se fija al CREAR y no cambia: el `PATCH` solo toca el cuerpo. Quien lo resuelve contra el contenido actual es el cliente."
          },
          "is_edited": {
            "type": "boolean",
            "description": "`true` si el comentario fue editado tras su creación."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-09T12:00:00Z"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-09T12:30:00Z"
            ]
          }
        }
      },
      "DocumentCommentAnchor": {
        "type": "object",
        "title": "DocumentCommentAnchor",
        "description": "Fragmento del documento sobre el que se comenta: texto citado + vecindad + id de bloque. El cliente lo resuelve contra el contenido actual.",
        "required": [
          "block_id",
          "quote",
          "prefix",
          "suffix"
        ],
        "properties": {
          "block_id": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 64,
            "description": "`data-id` del bloque de TipTap (párrafo, título, elemento de lista…) que contenía la selección. TipTap lo conserva al partir, unir, deshacer o pegar contenido, así que acota la búsqueda del texto citado aunque el párrafo se haya reescrito. Es `null` en documentos que todavía no se han vuelto a guardar con el editor (los bloques no tienen id hasta entonces) y entonces el texto se busca en todo el documento.",
            "examples": [
              "5e2f4b1a-9c3d-4e7f-8a1b-2c3d4e5f6a7b"
            ]
          },
          "quote": {
            "type": "string",
            "minLength": 1,
            "maxLength": 1000,
            "description": "Texto EXACTO seleccionado, con los espacios en blanco colapsados a uno solo. Es lo que se resalta y lo que el raíl de comentarios cita.",
            "examples": [
              "se resuelven en cuatro horas"
            ]
          },
          "prefix": {
            "type": "string",
            "maxLength": 100,
            "description": "Texto inmediatamente ANTERIOR a la cita (hasta 100 caracteres, espacios colapsados). Desempata cuando la misma cita aparece varias veces. Cadena vacía si la cita empieza el documento.",
            "examples": [
              "acusan recibo en una hora y "
            ]
          },
          "suffix": {
            "type": "string",
            "maxLength": 100,
            "description": "Texto inmediatamente POSTERIOR a la cita (hasta 100 caracteres, espacios colapsados). Cadena vacía si la cita termina el documento.",
            "examples": [
              " en horario laboral."
            ]
          }
        }
      },
      "DocumentFromTemplateIn": {
        "type": "object",
        "title": "DocumentFromTemplateIn",
        "description": "Alta de un documento a partir de una plantilla de la organización. Copia el contenido de la plantilla; el destino (carpeta, proyecto, padre) lo decide quien crea, no la plantilla.",
        "required": [
          "template_id"
        ],
        "properties": {
          "template_id": {
            "type": "string",
            "format": "uuid",
            "description": "Documento marcado como plantilla (`is_template: true`) del que copiar. `422` si el id no es una plantilla visible de esta organización.",
            "examples": [
              "7a1b2c3d-4e5f-4061-8273-8495a6b7c8d9"
            ]
          },
          "title": {
            "type": [
              "string",
              "null"
            ],
            "description": "Título del documento nuevo. Por defecto, el de la plantilla.",
            "examples": [
              "Onboarding de Ana"
            ]
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Proyecto al que asociarlo; `null` = suelto."
          },
          "folder_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Carpeta donde archivarlo; `null` = suelto. Es «la carpeta donde estás»."
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Documento padre en el árbol; `null` = raíz."
          }
        },
        "additionalProperties": false
      },
      "DocumentPublication": {
        "type": "object",
        "title": "DocumentPublication",
        "description": "Publicación de un documento: si está publicado y, en ese caso, el enlace público que lo abre sin sesión.",
        "required": [
          "document_id",
          "published",
          "token",
          "public_url",
          "published_at"
        ],
        "properties": {
          "document_id": {
            "type": "string",
            "format": "uuid",
            "description": "Documento al que se refiere esta publicación.",
            "examples": [
              "9c8b7a65-4321-4fed-cba9-876543210fed"
            ]
          },
          "published": {
            "type": "boolean",
            "description": "`true` si el enlace está vivo. Despublicar lo pone a `false` y el enlace pasa a responder `404`.",
            "examples": [
              true
            ]
          },
          "token": {
            "type": [
              "string",
              "null"
            ],
            "description": "Token público urlsafe (256 bits). `null` cuando el documento no está publicado. Es SIEMPRE el mismo mientras no se rote: despublicar y volver a publicar devuelve este mismo valor, para que un enlace ya compartido no muera por una despublicación temporal.",
            "examples": [
              "pJkT_RaNdOmUrLsAfE32ChArS"
            ]
          },
          "public_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL completa de la página pública; `null` si no está publicado.",
            "examples": [
              "https://app.projekt.3xa.es/d/pJkT_RaNdOmUrLsAfE32ChArS"
            ]
          },
          "published_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cuándo se publicó por última vez; `null` si nunca se publicó.",
            "examples": [
              "2026-08-23T10:00:00Z"
            ]
          }
        }
      },
      "DocumentPublishIn": {
        "type": "object",
        "title": "DocumentPublishIn",
        "description": "Opciones al publicar un documento.",
        "properties": {
          "rotate": {
            "type": "boolean",
            "default": false,
            "description": "`true` invalida el enlace anterior y emite uno nuevo. Es la única forma de matar un enlace filtrado para siempre: despublicar solo lo apaga, y volver a publicar devuelve el mismo enlace.",
            "examples": [
              false
            ]
          }
        },
        "additionalProperties": false
      },
      "PublicDocument": {
        "type": "object",
        "title": "PublicDocument",
        "description": "Documento publicado, en solo lectura y sin autenticación. Solo el contenido: ni ids internos, ni autor, ni comentarios, ni historial.",
        "required": [
          "title",
          "content",
          "updated_at"
        ],
        "properties": {
          "title": {
            "type": "string",
            "description": "Título del documento.",
            "examples": [
              "Manual de onboarding"
            ]
          },
          "content": {
            "type": [
              "string",
              "null"
            ],
            "description": "Contenido del documento (HTML del editor o markdown en los documentos antiguos). El cliente DEBE sanearlo antes de pintarlo — es contenido escrito por usuarios.",
            "examples": [
              "<h1>Bienvenida</h1><p>Pasos para empezar.</p>"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "Última modificación del documento.",
            "examples": [
              "2026-08-23T10:15:00Z"
            ]
          }
        }
      },
      "DashboardStats": {
        "type": "object",
        "title": "DashboardStats",
        "description": "Métricas agregadas del panel de inicio de una organización: recuento de tareas por estado, proyectos por estado, prioridad de tareas abiertas, carga por miembro, resumen financiero del rango y tendencia semanal de tareas.",
        "required": [
          "issues",
          "projects",
          "priority",
          "workload",
          "finance",
          "tasks_trend",
          "my_tasks",
          "most_active_projects"
        ],
        "properties": {
          "issues": {
            "type": "object",
            "description": "Recuento de tareas de la organización por los CUATRO estados base, con el total. `total` es la suma de los cuatro —`cancelled` incluido—, así que `todo + in_progress + done` no tiene por qué sumarlo; y una tarea que está en una columna de tablero PERSONALIZADA (p. ej. `in_review`) no cuenta en ninguno de los cuatro ni en el total.",
            "required": [
              "todo",
              "in_progress",
              "done",
              "cancelled",
              "total"
            ],
            "properties": {
              "todo": {
                "type": "integer",
                "examples": [
                  12
                ]
              },
              "in_progress": {
                "type": "integer",
                "examples": [
                  5
                ]
              },
              "done": {
                "type": "integer",
                "examples": [
                  30
                ]
              },
              "cancelled": {
                "type": "integer",
                "description": "Tareas canceladas. El API lo devuelve desde que existe el bloque y el contrato no lo declaraba (PJKT-2323): quien restaba `total - todo - in_progress - done` para «lo que falta» estaba contando canceladas sin saberlo.",
                "examples": [
                  2
                ]
              },
              "total": {
                "type": "integer",
                "examples": [
                  49
                ]
              }
            }
          },
          "projects": {
            "type": "object",
            "description": "Recuento de proyectos de la organización por estado, con el total.",
            "required": [
              "active",
              "archived",
              "total"
            ],
            "properties": {
              "active": {
                "type": "integer",
                "examples": [
                  4
                ]
              },
              "archived": {
                "type": "integer",
                "examples": [
                  1
                ]
              },
              "total": {
                "type": "integer",
                "examples": [
                  5
                ]
              }
            }
          },
          "priority": {
            "type": "array",
            "description": "Recuento de tareas abiertas (estado distinto de `done`) por prioridad; siempre las cuatro prioridades, con 0 cuando no hay.",
            "items": {
              "type": "object",
              "required": [
                "priority",
                "count"
              ],
              "properties": {
                "priority": {
                  "type": "string",
                  "enum": [
                    "low",
                    "medium",
                    "high",
                    "urgent"
                  ]
                },
                "count": {
                  "type": "integer",
                  "examples": [
                    3
                  ]
                }
              }
            }
          },
          "workload": {
            "type": "array",
            "description": "Carga por miembro (hasta 8, de más a menos asignadas): tareas asignadas abiertas y tareas completadas.",
            "items": {
              "type": "object",
              "required": [
                "user_id",
                "name",
                "assigned",
                "done"
              ],
              "properties": {
                "user_id": {
                  "type": "string",
                  "format": "uuid"
                },
                "name": {
                  "type": "string",
                  "examples": [
                    "Ana Pérez"
                  ]
                },
                "assigned": {
                  "type": "integer",
                  "description": "Tareas asignadas al miembro que no están `done`.",
                  "examples": [
                    6
                  ]
                },
                "done": {
                  "type": "integer",
                  "description": "Tareas del miembro en estado `done`.",
                  "examples": [
                    14
                  ]
                }
              }
            }
          },
          "finance": {
            "type": "object",
            "description": "Desglose financiero de la organización en el rango consultado. `revenue` = cobrado (facturas `paid`); `invoiced` = facturado (`sent` + `paid` + `overdue`); `outstanding` = pendiente de cobro (`sent` + `overdue`); `expenses` = coste del rango, el MISMO número que `expenses` de `GET /finance/summary`; `net` = revenue − expenses. Las tres primeras cifras cumplen `invoiced = revenue + outstanding`, porque lo pendiente de cobro es lo facturado que aún no ha entrado.",
            "required": [
              "revenue",
              "invoiced",
              "outstanding",
              "expenses",
              "expenses_by_kind",
              "net",
              "criterio"
            ],
            "properties": {
              "currency": {
                "type": "string",
                "minLength": 3,
                "maxLength": 3,
                "description": "Divisa ISO 4217 de los cinco importes — la base de la organización. Los agregados suman SOLO documentos emitidos en ella: antes eran `SUM(...)` ciegos a la divisa y la tarjeta de finanzas del panel pintaba euros y dólares en una misma cifra, sin decir de qué moneda hablaba (y `net` restaba gastos de una a ingresos de otra). No se convierte nada. Quien no puede ver finanzas recibe ceros, que también declaran la divisa.",
                "examples": [
                  "USD"
                ]
              },
              "revenue": {
                "type": "number",
                "description": "Cobrado — total de facturas `paid`.",
                "examples": [
                  48250
                ]
              },
              "invoiced": {
                "type": "number",
                "description": "Facturado — total de facturas `sent` + `paid` + `overdue`, el mismo criterio que `GET /agency/overview`, `GET /finance/trend`, `GET /finance/revenue-by-client` y `GET /finance/profitability/by-project`. Un BORRADOR no cuenta: no se le ha facturado a nadie. Hasta PJKT-2284 sí contaba, así que esta cifra era mayor que la del resto del producto sobre los mismos datos y rompía `invoiced = revenue + outstanding`.",
                "examples": [
                  60350
                ]
              },
              "outstanding": {
                "type": "number",
                "description": "Pendiente de cobro — facturas `sent` + `overdue`.",
                "examples": [
                  12100
                ]
              },
              "expenses": {
                "type": "number",
                "description": "Coste del rango: gastos normales aprobados MÁS facturas de proveedor recibidas que no estén `rejected`/`void`, con el anti-doble-conteo de `finance/cost_rules.py`. Es exactamente el `expenses` de `GET /finance/summary` sobre los mismos datos y el mismo rango. Hasta PJKT-2281 era `status='approved'` sin mirar `expenses.kind`: metía las facturas de proveedor aprobadas y dejaba fuera las `pending`/`paid`, de modo que la tarjeta de gastos del panel no cuadraba ni con el resumen de finanzas ni con el Modelo 303.",
                "examples": [
                  12100
                ]
              },
              "expenses_by_kind": {
                "type": "object",
                "description": "De qué se compone `expenses`, con el vocabulario de `expenses.kind` (mig 0134) — el mismo que devuelve la dimensión `kind` del dataset `expenses` de BI. `expense + supplier_invoice = expenses`.",
                "required": [
                  "expense",
                  "supplier_invoice"
                ],
                "properties": {
                  "expense": {
                    "type": "number",
                    "description": "Gastos normales aprobados (`kind='expense'`).",
                    "examples": [
                      4100
                    ]
                  },
                  "supplier_invoice": {
                    "type": "number",
                    "description": "Facturas de proveedor recibidas (`kind='supplier_invoice'`) en cualquier estado salvo `rejected`/`void` — una factura recibida es coste desde que entra, no desde que se aprueba.",
                    "examples": [
                      8000
                    ]
                  }
                }
              },
              "net": {
                "type": "number",
                "examples": [
                  36150
                ]
              },
              "criterio": {
                "$ref": "#/components/schemas/CriterioDeCalculo",
                "description": "De dónde salen los cinco importes de este bloque. Es el MISMO objeto que publica `GET /finance/summary` para el mismo rango, campo a campo: las dos pantallas enseñan el mismo dinero con el mismo criterio, y dos maneras distintas de explicarlo es exactamente cómo «facturado» llegó a valer dos cifras (PJKT-2283).\n\nQuien NO puede ver finanzas recibe los cinco importes a cero, y entonces este criterio viaja con `fuentes` y `descartes` VACÍOS: esos ceros no salen de ningún documento, así que declarar las tres familias con cero descartes afirmaría que se miró y no había nada. La divisa y las cotas sí viajan, que son las del panel que se ha pedido."
              }
            }
          },
          "tasks_trend": {
            "type": "array",
            "description": "Serie semanal de las últimas 8 semanas: tareas creadas y completadas por semana. `week` es el lunes ISO de la semana (`YYYY-MM-DD`).",
            "items": {
              "type": "object",
              "required": [
                "week",
                "created",
                "done"
              ],
              "properties": {
                "week": {
                  "type": "string",
                  "description": "Lunes de la semana en formato `YYYY-MM-DD`.",
                  "examples": [
                    "2026-07-06"
                  ]
                },
                "created": {
                  "type": "integer",
                  "description": "Tareas creadas esa semana (`created_at`).",
                  "examples": [
                    8
                  ]
                },
                "done": {
                  "type": "integer",
                  "description": "Tareas completadas esa semana (aprox. por `updated_at`).",
                  "examples": [
                    6
                  ]
                }
              }
            }
          },
          "my_tasks": {
            "type": "array",
            "description": "Tareas abiertas (estado distinto de `done`) asignadas al usuario actual (hasta 8), ordenadas por prioridad y vencimiento más próximo.",
            "items": {
              "type": "object",
              "required": [
                "id",
                "title",
                "status",
                "priority",
                "project_id",
                "project_name"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid"
                },
                "title": {
                  "type": "string",
                  "examples": [
                    "Cablear el panel de inicio"
                  ]
                },
                "status": {
                  "type": "string",
                  "description": "Estado de la tarea: uno de los 4 base (todo, in_progress, done, cancelled) o el slug de una columna de tablero PERSONALIZADA (`^[a-z0-9_]+$`, p.ej. `in_review`). NO es un enum cerrado."
                },
                "priority": {
                  "type": "string",
                  "enum": [
                    "low",
                    "medium",
                    "high",
                    "urgent"
                  ]
                },
                "project_id": {
                  "type": "string",
                  "format": "uuid",
                  "description": "UUID del proyecto al que pertenece la tarea."
                },
                "project_name": {
                  "type": "string",
                  "description": "Nombre del proyecto al que pertenece la tarea.",
                  "examples": [
                    "Projekt Web"
                  ]
                }
              }
            }
          },
          "most_active_projects": {
            "type": "array",
            "description": "Proyectos con más tareas de la organización (hasta 5, de más a menos tareas). Proyectos sin tareas no aparecen.",
            "items": {
              "type": "object",
              "required": [
                "id",
                "name",
                "task_total",
                "task_done"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid"
                },
                "name": {
                  "type": "string",
                  "examples": [
                    "Projekt Web"
                  ]
                },
                "task_total": {
                  "type": "integer",
                  "description": "Número total de tareas del proyecto.",
                  "examples": [
                    24
                  ]
                },
                "task_done": {
                  "type": "integer",
                  "description": "Número de tareas del proyecto en estado `done`.",
                  "examples": [
                    15
                  ]
                }
              }
            }
          }
        }
      },
      "DashboardActivityItem": {
        "type": "object",
        "title": "DashboardActivityItem",
        "description": "Evento reciente de auditoría de la organización, enriquecido con el nombre del actor y un resumen humano. `entity_type`/`entity_id` se derivan de la acción y del payload (audit_logs no los almacena como columnas). Si el actor no resuelve (id nulo/sintético), `actor_name` es \"Sistema\".",
        "required": [
          "id",
          "actor_name",
          "actor_avatar_url",
          "action",
          "entity_type",
          "entity_id",
          "created_at",
          "summary"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Identificador del registro de auditoría."
          },
          "actor_name": {
            "type": "string",
            "description": "Nombre del usuario que ejecutó la acción, o \"Sistema\".",
            "examples": [
              "Ana Pérez"
            ]
          },
          "actor_avatar_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL del avatar del actor; null si el actor es el sistema o no tiene foto.",
            "examples": [
              "https://lh3.googleusercontent.com/a/example"
            ]
          },
          "action": {
            "type": "string",
            "description": "Clave de la acción auditada (`<entidad>.<verbo>`).",
            "examples": [
              "invoice.created"
            ]
          },
          "entity_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "Tipo de entidad afectada (prefijo de la acción), o null.",
            "examples": [
              "invoice"
            ]
          },
          "entity_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Id de la entidad afectada (extraído del payload), o null.",
            "examples": [
              "3f2a1c9e-0b6d-4e5a-9c1f-8a7b6c5d4e3f"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "Instante del evento (UTC)."
          },
          "summary": {
            "type": "string",
            "description": "Resumen legible del evento (actor + acción + entidad).",
            "examples": [
              "Ana Pérez creó un(a) factura"
            ]
          }
        }
      },
      "ApiKey": {
        "type": "object",
        "title": "ApiKey",
        "description": "Personal Access Token (PAT) de una organización, en su representación enmascarada. El token en claro solo se devuelve una vez, al crearlo (`ApiKeyCreated`); aquí jamás aparece.",
        "required": [
          "id",
          "name",
          "project_id",
          "token_prefix",
          "created_by",
          "last_used_at",
          "revoked_at",
          "expires_at",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b"
            ]
          },
          "name": {
            "type": "string",
            "description": "Nombre legible de la key (1-120 chars).",
            "examples": [
              "CI deploy token"
            ]
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Proyecto al que está acotada la key. `null` = key org-wide (todos los proyectos, con el acceso completo del creador); un UUID = solo usable sobre los datos de ese proyecto.",
            "examples": [
              "9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"
            ]
          },
          "token_prefix": {
            "type": "string",
            "description": "Primeros caracteres del token en claro (p. ej. `pjk_live_ab12`) para identificar la key visualmente. No permite reconstruir el token.",
            "examples": [
              "pjk_live_ab12"
            ]
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del usuario que creó la key; `null` si se desconoce.",
            "examples": [
              "6f1f9c3e-8f2a-4b6e-9a3d-2b1c0d4e5f60"
            ]
          },
          "last_used_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Último uso registrado; `null` si nunca se ha usado.",
            "examples": [
              "2026-07-09T10:30:00Z"
            ]
          },
          "revoked_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Fecha de revocación; `null` si la key sigue activa.",
            "examples": [
              "2026-07-09T12:00:00Z"
            ]
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Fecha de caducidad de la key. `null` = no caduca nunca (retrocompat: las keys ya existentes siguen sin caducar). Una vez pasada, el PAT deja de autenticar (401) aunque no se haya revocado.",
            "examples": [
              "2027-07-09T09:00:00Z"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-09T09:00:00Z"
            ]
          }
        }
      },
      "ApiKeyCreated": {
        "type": "object",
        "title": "ApiKeyCreated",
        "description": "API key recién creada, incluyendo el token en claro `token`. Se devuelve una única vez al crear la key; guárdalo en ese momento porque no volverá a mostrarse (el servidor solo persiste su hash SHA-256).",
        "required": [
          "id",
          "name",
          "project_id",
          "token_prefix",
          "created_by",
          "last_used_at",
          "revoked_at",
          "expires_at",
          "created_at",
          "token"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b"
            ]
          },
          "name": {
            "type": "string",
            "description": "Nombre legible de la key (1-120 chars).",
            "examples": [
              "CI deploy token"
            ]
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Proyecto al que queda acotada la key. `null` = key org-wide; un UUID = solo usable sobre los datos de ese proyecto.",
            "examples": [
              "9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"
            ]
          },
          "token_prefix": {
            "type": "string",
            "description": "Primeros caracteres del token, para identificar la key en la UI.",
            "examples": [
              "pjk_live_ab12"
            ]
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del usuario que creó la key.",
            "examples": [
              "6f1f9c3e-8f2a-4b6e-9a3d-2b1c0d4e5f60"
            ]
          },
          "last_used_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Siempre `null` en la creación (aún no se ha usado).",
            "examples": [
              null
            ]
          },
          "revoked_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Siempre `null` en la creación.",
            "examples": [
              null
            ]
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Caducidad elegida al crear la key: el valor enviado en la petición, o `null` si se omitió (no caduca nunca).",
            "examples": [
              "2027-07-09T09:00:00Z"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-09T09:00:00Z"
            ]
          },
          "token": {
            "type": "string",
            "description": "Token en claro del PAT (`pjk_live_…`). SHOWN ONCE: solo aparece aquí, en la respuesta de creación; no se puede recuperar más tarde.",
            "examples": [
              "pjk_live_ab12cd34ef56ab78cd90ef12ab34cd56ef78ab90cd12ef34"
            ]
          }
        }
      },
      "ApiKeyScope": {
        "type": "object",
        "title": "ApiKeyScope",
        "description": "Scope de la API key (PAT) que autenticó la petición actual. Presente en `User.api_key` cuando se usa `Authorization: Bearer pjk_live_…`; `null` cuando la sesión es por cookie. Permite a los API clients (MCP, CLI, scripts) descubrir a qué organización y proyecto está acotada la key con la que operan.",
        "required": [
          "id",
          "name",
          "organization_id"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID de la API key que autenticó la petición.",
            "examples": [
              "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b"
            ]
          },
          "name": {
            "type": "string",
            "description": "Nombre legible de la key (1-120 chars).",
            "examples": [
              "MCP production token"
            ]
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID de la organización a la que pertenece la key.",
            "examples": [
              "6f1f9c3e-8f2a-4b6e-9a3d-2b1c0d4e5f60"
            ]
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del proyecto al que está acotada la key. `null` = key org-wide (todos los proyectos con el acceso completo del creador).",
            "examples": [
              "9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"
            ]
          }
        }
      },
      "StoreApp": {
        "type": "object",
        "title": "StoreApp",
        "description": "App instalable del catálogo de Projekt Republic. El `modelo` dice cómo se paga: `free` (gratis entera), `freemium` (parte gratis + parte de pago), `paid` (de pago entera, con trial) y `addon` (extensión de otra app, que se nombra en `addon_de`). `capacidades` es el reparto real gratis/premium y es la fuente que consulta el gate del servidor.",
        "required": [
          "key",
          "nombre",
          "descripcion",
          "icono",
          "modelo",
          "addon_de",
          "precio_mensual_cents",
          "precio_anual_cents",
          "moneda",
          "trial_dias",
          "incluida_en_pro",
          "solo_pro",
          "del_nucleo",
          "capacidades"
        ],
        "properties": {
          "key": {
            "type": "string",
            "description": "Clave estable de la app (PK del catálogo, p. ej. `finance`).",
            "examples": [
              "finance"
            ]
          },
          "nombre": {
            "type": "string",
            "description": "Nombre de la app tal y como se le enseña a la organización.",
            "examples": [
              "Finanzas"
            ]
          },
          "descripcion": {
            "type": "string",
            "description": "El párrafo de la ficha de la Store.",
            "examples": [
              "La mesa financiera completa: facturación, gastos, recurrentes…"
            ]
          },
          "icono": {
            "type": "string",
            "description": "Nombre del icono (familia lucide-react) con el que la app se pinta en la Store, en el dock y en ⌘K — el mismo en los tres sitios.",
            "examples": [
              "Wallet"
            ]
          },
          "modelo": {
            "type": "string",
            "enum": [
              "free",
              "freemium",
              "paid",
              "addon"
            ],
            "description": "Cómo se paga la app.",
            "examples": [
              "freemium"
            ]
          },
          "addon_de": {
            "type": [
              "string",
              "null"
            ],
            "description": "`key` de la app anfitriona cuando `modelo` es `addon`; `null` en el resto. Un addon no se puede instalar si su anfitriona no está instalada.",
            "examples": [
              "service_desk"
            ]
          },
          "precio_mensual_cents": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Precio mensual en céntimos de `moneda`. `null` = aún sin precio público (lo fija el dueño); NO significa gratis.",
            "examples": [
              1900
            ]
          },
          "precio_anual_cents": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Precio anual en céntimos de `moneda`; `null` = aún sin precio público.",
            "examples": [
              19000
            ]
          },
          "moneda": {
            "type": "string",
            "description": "Código ISO-4217 de la divisa de los precios.",
            "examples": [
              "EUR"
            ]
          },
          "trial_dias": {
            "type": "integer",
            "description": "Días de tramo premium gratis, una vez por organización y app. `0` en las apps `free`, que no tienen nada premium que probar.",
            "examples": [
              14
            ]
          },
          "incluida_en_pro": {
            "type": "boolean",
            "description": "¿La trae puesta el pack Pro? Hoy es `true` en todo el catálogo: la oferta es «Pro lo trae todo». Viaja en el catálogo público a propósito — es lo que deja escribir «Incluida en Pro» en la card sin inventarse un precio.",
            "examples": [
              true
            ]
          },
          "solo_pro": {
            "type": "boolean",
            "description": "¿La edición Free no puede tenerla NI PAGANDO? Hoy solo `kern_ai`. Distinto de no tener precio (eso es «aún no se ha puesto»): esto es «ahí no se vende», y el intento de instalarla en Free responde 422 `app_pro_only`.",
            "examples": [
              false
            ]
          },
          "del_nucleo": {
            "type": "boolean",
            "description": "¿Es del NÚCLEO del producto? Entonces NO se desinstala —el intento responde 422 `app_del_nucleo`— y el escritorio tampoco deja quitar su icono del dock. Es una sola declaración para las dos cosas: se puede ocultar del dock exactamente lo que se puede desinstalar de la Store.",
            "examples": [
              false
            ]
          },
          "capacidades": {
            "type": "array",
            "description": "Capacidades de la app con su tramo. Es la lista que pinta la ficha y la que consulta el gate: si algo no está aquí, el servidor no lo cobra.",
            "items": {
              "$ref": "#/components/schemas/StoreAppCapability"
            }
          }
        }
      },
      "StoreAppCapability": {
        "type": "object",
        "title": "StoreAppCapability",
        "description": "Capacidad interna de una app, con el tramo en el que cae. En una app `freemium` conviven capacidades `gratis` y `premium`; en una `free` todas son `gratis` y en una `paid`/`addon` todas son `premium`. El gate del servidor resuelve el acceso por esta lista, no por el nombre del plan.",
        "required": [
          "capacidad",
          "tramo",
          "descripcion"
        ],
        "properties": {
          "capacidad": {
            "type": "string",
            "description": "Clave estable de la capacidad, siempre con el prefijo de su app (`<app_key>.<algo>`). Es lo que pide el gate del servidor.",
            "examples": [
              "finance.recurring"
            ]
          },
          "tramo": {
            "type": "string",
            "enum": [
              "gratis",
              "premium"
            ],
            "description": "`gratis` = la incluye la mera instalación de la app; `premium` = exige suscripción viva (activa, trial o en gracia) de esa app.",
            "examples": [
              "premium"
            ]
          },
          "descripcion": {
            "type": "string",
            "description": "Qué hace la capacidad, en una línea y sin jerga.",
            "examples": [
              "Facturas recurrentes que se emiten solas"
            ]
          }
        }
      },
      "StoreAppForOrganization": {
        "type": "object",
        "title": "StoreAppForOrganization",
        "description": "App del catálogo con el estado de una organización: si la tiene instalada, desde cuándo, su suscripción, si puede instalarla ahora y qué vínculos vivos impiden desinstalarla.",
        "required": [
          "key",
          "nombre",
          "descripcion",
          "icono",
          "modelo",
          "addon_de",
          "precio_mensual_cents",
          "precio_anual_cents",
          "moneda",
          "trial_dias",
          "incluida_en_pro",
          "solo_pro",
          "del_nucleo",
          "capacidades",
          "instalada",
          "instalada_en",
          "suscripcion",
          "puede_instalar",
          "dependencias"
        ],
        "properties": {
          "key": {
            "type": "string",
            "description": "Clave estable de la app (PK del catálogo, p. ej. `finance`).",
            "examples": [
              "finance"
            ]
          },
          "nombre": {
            "type": "string",
            "description": "Nombre de la app tal y como se le enseña a la organización.",
            "examples": [
              "Finanzas"
            ]
          },
          "descripcion": {
            "type": "string",
            "description": "El párrafo de la ficha de la Store.",
            "examples": [
              "La mesa financiera completa: facturación, gastos, recurrentes…"
            ]
          },
          "icono": {
            "type": "string",
            "description": "Nombre del icono (familia lucide-react) con el que se pinta la app.",
            "examples": [
              "Wallet"
            ]
          },
          "modelo": {
            "type": "string",
            "enum": [
              "free",
              "freemium",
              "paid",
              "addon"
            ],
            "description": "Cómo se paga la app.",
            "examples": [
              "freemium"
            ]
          },
          "addon_de": {
            "type": [
              "string",
              "null"
            ],
            "description": "`key` de la app anfitriona si `modelo` es `addon`; `null` si no.",
            "examples": [
              "service_desk"
            ]
          },
          "precio_mensual_cents": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Precio mensual en céntimos; `null` = aún sin precio público.",
            "examples": [
              1900
            ]
          },
          "precio_anual_cents": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Precio anual en céntimos; `null` = aún sin precio público.",
            "examples": [
              19000
            ]
          },
          "moneda": {
            "type": "string",
            "description": "Código ISO-4217 de la divisa de los precios.",
            "examples": [
              "EUR"
            ]
          },
          "trial_dias": {
            "type": "integer",
            "description": "Días de premium gratis por organización y app (`0` en las apps `free`).",
            "examples": [
              14
            ]
          },
          "incluida_en_pro": {
            "type": "boolean",
            "description": "¿La trae puesta el pack Pro? Hoy es `true` en todo el catálogo: la oferta es «Pro lo trae todo». Viaja en el catálogo público a propósito — es lo que deja escribir «Incluida en Pro» en la card sin inventarse un precio.",
            "examples": [
              true
            ]
          },
          "solo_pro": {
            "type": "boolean",
            "description": "¿La edición Free no puede tenerla NI PAGANDO? Hoy solo `kern_ai`. Distinto de no tener precio (eso es «aún no se ha puesto»): esto es «ahí no se vende», y el intento de instalarla en Free responde 422 `app_pro_only`.",
            "examples": [
              false
            ]
          },
          "del_nucleo": {
            "type": "boolean",
            "description": "¿Es del NÚCLEO del producto? Entonces NO se desinstala —el intento responde 422 `app_del_nucleo`— y el escritorio tampoco deja quitar su icono del dock. Una sola declaración para las dos cosas.",
            "examples": [
              false
            ]
          },
          "capacidades": {
            "type": "array",
            "description": "Capacidades de la app con su tramo (gratis/premium).",
            "items": {
              "$ref": "#/components/schemas/StoreAppCapability"
            }
          },
          "instalada": {
            "type": "boolean",
            "description": "`true` si la organización la tiene instalada AHORA. Instalar es gratis siempre y da acceso al tramo gratis; desinstalar no borra datos.",
            "examples": [
              true
            ]
          },
          "instalada_en": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cuándo empezó la instalación ACTUAL; `null` si no está instalada. Reinstalar reinicia esta marca (el histórico no vive en el catálogo).",
            "examples": [
              "2026-08-23T09:00:00Z"
            ]
          },
          "suscripcion": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/StoreAppSubscription"
              },
              {
                "type": "null"
              }
            ],
            "description": "Suscripción de la organización a esta app, o `null` si nunca la probó ni la pagó. Viaja también cuando ya no concede nada (una prueba caducada, una baja ya vencida): es lo que hace que el botón diga «Suscribirse» en vez de «Probar 14 días». Si concede AHORA lo dice su campo `viva`."
          },
          "puede_instalar": {
            "type": "boolean",
            "description": "`true` si un admin puede instalarla ahora mismo. Es `false` cuando ya está instalada, y cuando es un `addon` cuya app anfitriona NO lo está —ese intento responde 422 `app_requires_parent`—.",
            "examples": [
              false
            ]
          },
          "dependencias": {
            "type": "object",
            "additionalProperties": {
              "type": "integer"
            },
            "description": "Vínculos VIVOS entre apps que bloquean desinstalar ésta, indexados por una etiqueta legible («gastos imputados a proyectos») con su número. Vacío = se puede desinstalar. Es la misma medida que ya usa el interruptor de módulos: solo cuenta vínculos CRUZADOS, nunca los datos propios de la app.",
            "examples": [
              {
                "facturas imputadas a proyectos": 12
              }
            ]
          }
        }
      },
      "StoreAppSubscription": {
        "type": "object",
        "title": "StoreAppSubscription",
        "description": "Suscripción de una organización a una app: qué estado tiene, con qué periodicidad se cobra, hasta cuándo concede y cuánto cuesta. `trial`, `activa` y `en_gracia` conceden el tramo premium; `cancelada` sigue concediéndolo mientras quede periodo pagado. `en_gracia` es el periodo de aviso tras un pago fallido: se avisa, no se corta en seco.",
        "required": [
          "app_key",
          "estado",
          "periodo",
          "viva",
          "trial_hasta",
          "renueva_en",
          "cancelada_en",
          "importe_cents",
          "moneda"
        ],
        "properties": {
          "app_key": {
            "type": "string",
            "description": "`key` de la app a la que corresponde esta suscripción.",
            "examples": [
              "finance"
            ]
          },
          "estado": {
            "type": "string",
            "enum": [
              "trial",
              "activa",
              "en_gracia",
              "cancelada"
            ],
            "description": "`trial` (prueba en curso, sin tarjeta), `activa` (se cobra), `en_gracia` (falló un cobro y Stripe lo está reintentando) o `cancelada` (se pidió la baja al fin de periodo). Para saber si CONCEDE ahora mismo, mirar `viva`.",
            "examples": [
              "activa"
            ]
          },
          "periodo": {
            "type": "string",
            "enum": [
              "mensual",
              "anual"
            ],
            "description": "Periodicidad del cobro.",
            "examples": [
              "mensual"
            ]
          },
          "viva": {
            "type": "boolean",
            "description": "`true` si AHORA MISMO concede el tramo premium de la app. Es exactamente lo que mira el gate del servidor, ya calculado: no se deduce del estado ni de las fechas (una `cancelada` con periodo pagado por delante es `true`; un `trial` caducado es `false`).",
            "examples": [
              true
            ]
          },
          "trial_hasta": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Fin de la prueba; `null` si esta organización nunca la usó en esta app. NO se limpia al pasar a pago: es lo que recuerda que el trial ya se gastó (es de una vez por organización y app) y lo que hace que pedir otro responda 409 `trial_already_used`.",
            "examples": [
              "2026-09-06T00:00:00Z"
            ]
          },
          "renueva_en": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Fin del periodo pagado en curso. En una suscripción `activa` es cuándo se vuelve a cobrar; en una `cancelada` es hasta cuándo se sigue disfrutando lo ya pagado. `null` en un trial (no hay periodo pagado).",
            "examples": [
              "2026-09-23T00:00:00Z"
            ]
          },
          "cancelada_en": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cuándo se pidió la baja; `null` mientras no se haya pedido. Se escribe una sola vez — es cuándo se canceló, no cuándo se procesó el último evento.",
            "examples": [
              "2026-09-20T10:00:00Z"
            ]
          },
          "importe_cents": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Lo que cuesta la app HOY con esta periodicidad, en céntimos, tal y como está en el catálogo. `null` si aún no tiene precio público. **No es lo que se cobró**: si el precio ha subido desde la última renovación, aquí se ve el nuevo aunque esta organización siga pagando el viejo hasta que le toque renovar. El importe exacto de cada cobro está en las facturas de Stripe.",
            "examples": [
              1900
            ]
          },
          "moneda": {
            "type": "string",
            "description": "Código ISO-4217 de la divisa de `importe_cents`.",
            "examples": [
              "EUR"
            ]
          }
        }
      },
      "StoreSubscribeRequest": {
        "type": "object",
        "title": "StoreSubscribeRequest",
        "description": "Con qué periodicidad se quiere pagar la app.",
        "properties": {
          "periodo": {
            "type": "string",
            "enum": [
              "mensual",
              "anual"
            ],
            "default": "mensual",
            "description": "Periodicidad del cobro. Si la app no tiene precio publicado para la periodicidad pedida, la respuesta es 422 `app_not_purchasable`.",
            "examples": [
              "mensual"
            ]
          }
        }
      },
      "StoreSubscribeResult": {
        "type": "object",
        "title": "StoreSubscribeResult",
        "description": "O la suscripción ya activa (la app se añadió a la suscripción existente de la organización), o la URL de Stripe Checkout a la que redirigir (primera compra). Exactamente uno de los dos campos es no nulo.",
        "required": [
          "checkout_url",
          "suscripcion"
        ],
        "properties": {
          "checkout_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL de Stripe Checkout a la que redirigir al usuario; `null` si no hizo falta pasar por ahí.",
            "examples": [
              "https://checkout.stripe.com/c/pay/cs_test_a1b2c3"
            ]
          },
          "suscripcion": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/StoreAppSubscription"
              },
              {
                "type": "null"
              }
            ],
            "description": "La suscripción ya activa; `null` si hay que completar el pago en `checkout_url` primero."
          }
        }
      },
      "VaultItem": {
        "type": "object",
        "title": "VaultItem",
        "description": "Credencial compartida del vault de una organización (p. ej. la suscripción de un software), en su representación SIN secreto. El secreto se guarda cifrado en reposo y solo se obtiene vía `GET …/vault/{item_id}/reveal`, que audita cada acceso.",
        "required": [
          "id",
          "organization_id",
          "name",
          "username",
          "url",
          "notes",
          "shared_with",
          "shared_user_ids",
          "created_by",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "5c0e8a1d-2b3f-4c6a-9d7e-8f1a2b3c4d5e"
            ]
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d"
            ]
          },
          "name": {
            "type": "string",
            "description": "Nombre legible de la credencial (1-120 chars).",
            "examples": [
              "Figma (equipo de diseño)"
            ]
          },
          "username": {
            "type": [
              "string",
              "null"
            ],
            "description": "Usuario/identificador de la cuenta; `null` si no aplica.",
            "examples": [
              "design@3xa.es"
            ]
          },
          "url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL del servicio al que pertenece la credencial; `null` si no aplica.",
            "examples": [
              "https://www.figma.com"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "description": "Notas libres (NO cifradas: nunca guardes aquí el secreto); `null` si no hay.",
            "examples": [
              "Plan Professional, renueva en enero."
            ]
          },
          "shared_with": {
            "type": "string",
            "enum": [
              "org",
              "admins",
              "usuarios"
            ],
            "description": "Alcance de compartición: `org` = visible para todos los miembros; `admins` = solo owner/admin la ven (y solo ellos pueden revelarla); `usuarios` = solo las personas de `shared_user_ids`, más quien la creó y los owner/admin de la organización.",
            "examples": [
              "org"
            ]
          },
          "shared_user_ids": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Personas con las que se comparte cuando `shared_with=usuarios` (el caso «esta suscripción la ven solo estas tres personas»). Siempre presente: lista VACÍA cuando no hay destinatarios. Con `shared_with=org`/`admins` la lista se conserva pero NO otorga acceso: manda `shared_with`.",
            "examples": [
              [
                "6f1f9c3e-8f2a-4b6e-9a3d-2b1c0d4e5f60",
                "9b2d1e4f-3a5c-4d7e-8f90-1a2b3c4d5e6f"
              ]
            ]
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del usuario que la creó; `null` si esa cuenta ya no existe.",
            "examples": [
              "6f1f9c3e-8f2a-4b6e-9a3d-2b1c0d4e5f60"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-08-20T09:00:00Z"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-08-21T10:30:00Z"
            ]
          }
        }
      },
      "VaultItemCreate": {
        "type": "object",
        "title": "VaultItemCreate",
        "description": "Datos para crear una credencial en el vault. Requiere rol owner/admin. El `secret` se cifra en reposo y no vuelve en la respuesta: para leerlo hay que usar el endpoint de revelado (auditado).",
        "required": [
          "name",
          "secret"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "Nombre legible de la credencial (1-120 chars).",
            "examples": [
              "Figma (equipo de diseño)"
            ]
          },
          "secret": {
            "type": "string",
            "description": "El secreto en claro (contraseña, API key…). Se cifra en reposo; nunca aparece en listados ni en esta misma respuesta.",
            "examples": [
              "hunter2-pero-larga"
            ]
          },
          "username": {
            "type": [
              "string",
              "null"
            ],
            "description": "Usuario/identificador de la cuenta. Omitir/`null` = sin usuario.",
            "examples": [
              "design@3xa.es"
            ]
          },
          "url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL del servicio. Omitir/`null` = sin URL.",
            "examples": [
              "https://www.figma.com"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "description": "Notas libres (NO cifradas: nunca pongas aquí el secreto). Omitir/`null` = sin notas.",
            "examples": [
              "Plan Professional, renueva en enero."
            ]
          },
          "shared_with": {
            "type": "string",
            "enum": [
              "org",
              "admins",
              "usuarios"
            ],
            "default": "org",
            "description": "Alcance de compartición: `org` (por defecto) = todos los miembros la ven; `admins` = solo owner/admin; `usuarios` = solo las personas de `shared_user_ids` (más quien la crea y los owner/admin de la org)."
          },
          "shared_user_ids": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "maxItems": 200,
            "default": [],
            "description": "Personas con las que compartirla; solo otorga acceso cuando `shared_with=usuarios`. TODAS tienen que ser miembros vivos de esta organización: si alguna no lo es, la petición entera se rechaza con 422 `shared_user_not_member` (nunca se aceptan a medias). Omitir = lista vacía.",
            "examples": [
              [
                "6f1f9c3e-8f2a-4b6e-9a3d-2b1c0d4e5f60",
                "9b2d1e4f-3a5c-4d7e-8f90-1a2b3c4d5e6f"
              ]
            ]
          }
        }
      },
      "VaultItemUpdate": {
        "type": "object",
        "title": "VaultItemUpdate",
        "description": "Edición parcial de una credencial del vault (owner/admin). Si llega `secret` se re-cifra y sustituye al anterior. En `username`/`url`/`notes` un `null` explícito borra el valor; omitir un campo lo conserva.",
        "properties": {
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nuevo nombre (1-120 chars). Omitir/`null` = sin cambio.",
            "examples": [
              "Figma (todo el estudio)"
            ]
          },
          "secret": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nuevo secreto en claro; se re-cifra en reposo. Omitir/`null` = conserva el secreto actual."
          },
          "username": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nuevo usuario. `null` explícito = borrar; omitir = sin cambio.",
            "examples": [
              "studio@3xa.es"
            ]
          },
          "url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nueva URL. `null` explícito = borrar; omitir = sin cambio.",
            "examples": [
              "https://www.figma.com"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nuevas notas. `null` explícito = borrar; omitir = sin cambio."
          },
          "shared_with": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "org",
              "admins",
              "usuarios",
              null
            ],
            "description": "Nuevo alcance de compartición (`org`/`admins`/`usuarios`). Omitir/`null` = sin cambio."
          },
          "shared_user_ids": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "maxItems": 200,
            "description": "Nueva lista COMPLETA de personas con las que se comparte (reemplaza a la anterior, no la suma): mandar `[]` deja la credencial sin destinatarios y quita el acceso al instante. Omitir/`null` = sin cambio. TODAS tienen que ser miembros vivos de esta organización o la petición se rechaza entera con 422 `shared_user_not_member`.",
            "examples": [
              [
                "6f1f9c3e-8f2a-4b6e-9a3d-2b1c0d4e5f60"
              ]
            ]
          }
        }
      },
      "VaultSecretOut": {
        "type": "object",
        "title": "VaultSecretOut",
        "description": "Secreto descifrado de una credencial del vault, devuelto SOLO por el endpoint de revelado explícito. Cada revelado queda auditado (quién, qué y cuándo). Para items `shared_with=admins` requiere rol owner/admin.",
        "required": [
          "id",
          "name",
          "username",
          "secret"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "5c0e8a1d-2b3f-4c6a-9d7e-8f1a2b3c4d5e"
            ]
          },
          "name": {
            "type": "string",
            "description": "Nombre de la credencial, para confirmar qué se está revelando.",
            "examples": [
              "Figma (equipo de diseño)"
            ]
          },
          "username": {
            "type": [
              "string",
              "null"
            ],
            "description": "Usuario/identificador de la cuenta; `null` si no aplica.",
            "examples": [
              "design@3xa.es"
            ]
          },
          "secret": {
            "type": "string",
            "description": "El secreto en claro. NUNCA lo persistas ni lo loguees."
          }
        }
      },
      "Notification": {
        "type": "object",
        "title": "Notification",
        "description": "Aviso in-app del usuario autenticado dentro de una organización. `read_at` es `null` si aún no se ha leído. `link` es una ruta in-app opcional a la que navegar (p. ej. `/finance/invoices/INV-0007`).",
        "required": [
          "id",
          "user_id",
          "organization_id",
          "type",
          "category",
          "actor_id",
          "entity_type",
          "entity_id",
          "title",
          "body",
          "link",
          "read_at",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "9c1f2e3d-4a5b-4c6d-8e7f-0a1b2c3d4e5f"
            ]
          },
          "user_id": {
            "type": "string",
            "format": "uuid",
            "description": "Destinatario de la notificación (el usuario autenticado)."
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "description": "Organización en cuyo contexto se emitió la notificación."
          },
          "type": {
            "type": "string",
            "description": "Tipo de evento que originó la notificación. Determina el icono/estilo en el cliente; `generic` es el comodín para tipos aún no catalogados.",
            "enum": [
              "task_assigned",
              "task_due_soon",
              "task_overdue",
              "task.unassigned",
              "task.unblocked",
              "task.status_changed",
              "comment",
              "mention",
              "project.announcement",
              "project.member_added",
              "project.status_changed",
              "sprint.started",
              "sprint.completed",
              "sprint.ending",
              "crossorg_link",
              "billing.past_due",
              "invoice_approved",
              "supplier_invoice_approved",
              "quote.accepted",
              "quote.rejected",
              "supplier_invoice.due_soon",
              "budget.exceeded",
              "kern.repaso_finance",
              "kern.repaso_quotes",
              "kern.repaso_projects",
              "invoice.reminder_sent",
              "leave.requested",
              "leave.approved",
              "leave.rejected",
              "timesheet.submitted",
              "timesheet.approved",
              "timesheet.rejected",
              "payslip.published",
              "timesheet.approval_pending",
              "leave.balance_low",
              "automation",
              "org_member_added",
              "org_invite_accepted",
              "org_role_changed",
              "chat_mention",
              "chat_message",
              "meeting_invite",
              "meeting_invite_response",
              "meeting_reminder",
              "meeting_call",
              "meeting_admission_request",
              "meeting_admission_decided",
              "deal.closed",
              "deal.assigned",
              "sla.at_risk",
              "sla.breached",
              "reminder_due",
              "announcement",
              "generic"
            ]
          },
          "category": {
            "type": "string",
            "description": "Categoría del evento (agrupación / icono / preferencias). Derivada del `type` según el registro de eventos.",
            "enum": [
              "tasks",
              "comments",
              "finance",
              "hr",
              "projects",
              "crossorg",
              "org",
              "automation",
              "system",
              "chat",
              "crm",
              "billing",
              "sla",
              "reminders"
            ]
          },
          "actor_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Usuario que originó el evento (para agrupar/avatar); `null` si no aplica."
          },
          "entity_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "Tipo del objeto de dominio referido (task, invoice…); clave de agrupación."
          },
          "entity_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del objeto de dominio referido; `null` si no aplica."
          },
          "title": {
            "type": "string",
            "description": "Título breve de la notificación.",
            "examples": [
              "Se te asignó «Cablear la campana»"
            ]
          },
          "body": {
            "type": [
              "string",
              "null"
            ],
            "description": "Cuerpo/detalle opcional; `null` si no lo hay."
          },
          "link": {
            "type": [
              "string",
              "null"
            ],
            "description": "Ruta in-app a la que navegar al pulsar; `null` si no aplica.",
            "examples": [
              "/finance/invoices/INV-0007"
            ]
          },
          "read_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Fecha de lectura; `null` si no se ha leído."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-09T12:00:00Z"
            ]
          }
        }
      },
      "UnreadCount": {
        "type": "object",
        "title": "UnreadCount",
        "description": "Número de notificaciones sin leer del usuario en la organización actual.",
        "required": [
          "count"
        ],
        "properties": {
          "count": {
            "type": "integer",
            "description": "Notificaciones sin leer (>= 0).",
            "examples": [
              3
            ]
          }
        }
      },
      "Entitlements": {
        "type": "object",
        "title": "Entitlements",
        "required": [
          "plan",
          "features",
          "limits",
          "usage",
          "disabled_modules"
        ],
        "properties": {
          "plan": {
            "type": "string",
            "description": "Plan freemium efectivo de la organización.",
            "enum": [
              "free",
              "equipo",
              "projekt"
            ]
          },
          "features": {
            "type": "array",
            "description": "Claves de las funcionalidades desbloqueadas por el plan.",
            "items": {
              "type": "string"
            }
          },
          "disabled_modules": {
            "type": "array",
            "description": "Claves que la ORGANIZACIÓN se ha apagado ella misma en Ajustes › Módulos (`PUT /organizations/{org_id}/modules/{key}`). Es un subconjunto de lo que el plan concede y NO aparece en `features`: la lista de arriba es la resolución efectiva. Existe para que la interfaz pueda separar «tu plan no lo incluye — mejora el plan» de «lo habéis apagado vosotros — un admin puede encenderlo», que sin esto se anuncian con el mismo texto. También la usa la navegación para ocultar lo que la organización apagó y el plan no gatea (hoy: Mensajes).",
            "items": {
              "type": "string"
            }
          },
          "limits": {
            "type": "object",
            "title": "EntitlementLimits",
            "description": "Límites del plan. `null` = ilimitado.",
            "required": [
              "max_projects",
              "max_members",
              "storage_gb"
            ],
            "properties": {
              "max_projects": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "max_members": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "storage_gb": {
                "type": [
                  "integer",
                  "null"
                ]
              }
            }
          },
          "usage": {
            "type": "object",
            "title": "EntitlementUsage",
            "description": "Uso actual de la organización (para comparar con los límites).",
            "required": [
              "projects",
              "members",
              "empleados",
              "storage_bytes"
            ],
            "properties": {
              "projects": {
                "type": "integer",
                "description": "Proyectos VIVOS de la organización. Los de la papelera no ocupan cupo: borrar un proyecto libera plaza de inmediato."
              },
              "members": {
                "type": "integer",
                "description": "Personas con acceso al producto en esta organización."
              },
              "empleados": {
                "type": "integer",
                "description": "PLANTILLA, que no es lo mismo que `members`: son las fichas de empleado ACTIVAS, incluidas las de quien no tiene usuario. Es la unidad con la que se cobran Workforce y Nóminas — cuentan gente que puede no abrir Projekt en su vida—, y confundirla con `members` es cobrar por ocho a quien gestiona cuarenta."
              },
              "storage_bytes": {
                "type": "integer",
                "description": "Bytes que ocupan los ficheros de la organización: adjuntos genéricos, adjuntos de tarea, adjuntos de chat y documentos de tipo fichero. Es la MISMA suma con la que se aplica el tope del plan, para que lo que se enseña y lo que se deniega no puedan discrepar.\nEn BYTES y no en GB, aunque el límite (`limits.storage_gb`) venga en GB: redondear aquí haría que una organización con 400 MB leyera «0 GB de 1» — un cero que dice «no has subido nada» cuando llevas casi la mitad del cupo. La unidad la elige quien lo pinta."
              }
            }
          },
          "subscription": {
            "type": [
              "object",
              "null"
            ],
            "title": "SubscriptionInfo",
            "description": "Datos de la suscripción de Stripe cuando la organización tiene un plan de pago con cliente en Stripe. `null` en plan Free o si el plan se asignó a mano sin pasar por checkout.",
            "required": [
              "status"
            ],
            "properties": {
              "status": {
                "type": "string",
                "description": "Estado en Stripe (active, trialing, past_due, canceled…)."
              },
              "renews_at": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time",
                "description": "Fin del periodo pagado actual = próxima fecha de renovación/cobro y momento en que se aplicaría un cambio de plan programado. `null` si se desconoce."
              }
            }
          }
        }
      },
      "CheckoutSessionCreate": {
        "type": "object",
        "title": "CheckoutSessionCreate",
        "required": [
          "plan"
        ],
        "properties": {
          "plan": {
            "type": "string",
            "description": "Plan de pago a contratar. `free` no es comprable.",
            "enum": [
              "equipo",
              "projekt"
            ]
          },
          "billing_period": {
            "type": "string",
            "description": "Periodicidad de facturación del plan. `annual` usa el price id anual (interval=year) de Stripe; si el plan no tiene price anual configurado, se usa el mensual como fallback.",
            "enum": [
              "monthly",
              "annual"
            ],
            "default": "monthly"
          },
          "promo_code": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 64,
            "description": "Código promocional opcional (PJKT-1980). Se valida contra `promo_codes`; si no existe o ha caducado, la sesión se crea igualmente sin descuento.\nEl API lo aceptaba desde el principio y el contrato no lo declaraba (encontrado en PJKT-2026): funcionaba porque el frontend lo mete con un spread, que esquiva la comprobación de propiedades sobrantes de TypeScript. Es decir, funcionaba por accidente."
          },
          "success_path": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 512,
            "description": "Ruta INTERNA de la app (dominio propio) a la que devolver al usuario tras pagar, en vez del destino por defecto `/settings/billing`. Se usa para continuar el asistente de puesta en marcha donde se dejó tras subir de plan (p. ej. `/dashboard?setup=resume`).\nSEGURIDAD (anti open-redirect): el backend solo la respeta si empieza por una sola `/` y no por `//` ni contiene un esquema (`http:`, `javascript:`…); en cualquier otro caso cae al destino por defecto. Nunca es una URL absoluta."
          }
        }
      },
      "PromoCode": {
        "type": "object",
        "title": "PromoCode",
        "description": "Beneficio de un código promocional, para anunciarlo antes de pagar. No expone contadores de canjes ni el coupon de Stripe: eso es de plataforma.",
        "required": [
          "code",
          "description",
          "kind"
        ],
        "properties": {
          "code": {
            "type": "string",
            "description": "Código en mayúsculas; también es el enlace (`/upgrade?promo=CODE`).",
            "examples": [
              "VERANO3"
            ]
          },
          "description": {
            "type": "string",
            "description": "Texto para el usuario, se muestra tal cual.",
            "examples": [
              "3 meses gratis de Business"
            ]
          },
          "kind": {
            "type": "string",
            "enum": [
              "free_period",
              "discount"
            ],
            "description": "`free_period` = N meses gratis (periodo de prueba de la suscripción: no se cobra nada hasta que acaba). `discount` = descuento sobre la suscripción."
          },
          "plan": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "equipo",
              "projekt",
              null
            ],
            "description": "Plan al que aplica; ausente/null = cualquier plan de pago."
          },
          "free_months": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Meses gratis (solo en `free_period`).",
            "examples": [
              3
            ]
          },
          "percent_off": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Descuento porcentual (solo en `discount`; excluyente con importe)."
          },
          "amount_off_minor": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "description": "Descuento en céntimos de `currency` (excluyente con el porcentual)."
          },
          "currency": {
            "type": [
              "string",
              "null"
            ],
            "description": "ISO 4217 en minúsculas; obligatoria si el descuento es por importe."
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Fin de validez; null = sin caducidad."
          }
        }
      },
      "PromoCodeAdmin": {
        "allOf": [
          {
            "$ref": "#/components/schemas/PromoCode"
          },
          {
            "type": "object",
            "title": "PromoCodeAdmin",
            "description": "Código promocional con su estado operativo (solo platform admin).",
            "required": [
              "id",
              "redeemed_count",
              "active",
              "created_at"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "stripe_coupon_id": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Coupon de Stripe que aplica el descuento (null en `free_period`)."
              },
              "max_redemptions": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Límite total de canjes; null = sin límite."
              },
              "redeemed_count": {
                "type": "integer",
                "description": "Canjes consumidos."
              },
              "starts_at": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time"
              },
              "active": {
                "type": "boolean",
                "description": "Interruptor manual; false = ya no se puede canjear."
              },
              "created_at": {
                "type": "string",
                "format": "date-time"
              }
            }
          }
        ]
      },
      "PromoCodeCreate": {
        "type": "object",
        "title": "PromoCodeCreate",
        "description": "Crea un código promocional. La coherencia entre `kind` y los campos que lo cuantifican se valida en el servidor (`422 promo_invalid`): `free_period` exige `free_months`; `discount` exige `percent_off` O `amount_off_minor` (nunca ambos), su `currency` si es importe, y el `stripe_coupon_id` que lo aplica en Stripe.",
        "required": [
          "code",
          "description",
          "kind"
        ],
        "properties": {
          "code": {
            "type": "string",
            "minLength": 3,
            "maxLength": 64,
            "pattern": "^[A-Za-z0-9._-]+$",
            "description": "Se normaliza a MAYÚSCULAS. 409 `promo_code_taken` si ya existe."
          },
          "description": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255
          },
          "kind": {
            "type": "string",
            "enum": [
              "free_period",
              "discount"
            ]
          },
          "plan": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "equipo",
              "projekt",
              null
            ]
          },
          "free_months": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "maximum": 36
          },
          "percent_off": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "maximum": 100
          },
          "amount_off_minor": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "minimum": 1
          },
          "currency": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 3,
            "maxLength": 3
          },
          "stripe_coupon_id": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255
          },
          "max_redemptions": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1
          },
          "starts_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "PromoCodeUpdate": {
        "type": "object",
        "title": "PromoCodeUpdate",
        "description": "Desactiva el código, recorta su caducidad o cambia su límite de usos. Lo que el código CONCEDE no se edita: con el enlace ya repartido, cambiarlo por debajo es peor que crear otro código.",
        "properties": {
          "active": {
            "type": "boolean"
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "max_redemptions": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1
          }
        }
      },
      "PromoRedemption": {
        "type": "object",
        "title": "PromoRedemption",
        "description": "Canje de un código promocional por una organización. `redeemed_at` es cuando se creó la sesión de checkout con el código (que es cuando se consume el canje), no cuando se cobró.",
        "required": [
          "organization_id",
          "organization_name",
          "redeemed_at",
          "plan"
        ],
        "properties": {
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "description": "Organización que canjeó el código (enlazable a su ficha)."
          },
          "organization_name": {
            "type": "string",
            "description": "Nombre actual de la organización."
          },
          "redeemed_at": {
            "type": "string",
            "format": "date-time",
            "description": "Momento del canje (creación de la sesión de checkout)."
          },
          "plan": {
            "type": [
              "string",
              "null"
            ],
            "description": "Plan del checkout en el que se canjeó; null si no consta."
          }
        }
      },
      "BillingRedirect": {
        "type": "object",
        "title": "BillingRedirect",
        "required": [
          "url"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "description": "URL de Stripe a la que redirigir al usuario."
          }
        }
      },
      "BillingWebhookAck": {
        "type": "object",
        "title": "BillingWebhookAck",
        "required": [
          "received"
        ],
        "properties": {
          "received": {
            "type": "boolean",
            "description": "`true` cuando la firma es válida y el evento se procesó."
          }
        }
      },
      "OrganizationModule": {
        "type": "object",
        "title": "OrganizationModule",
        "description": "Módulo del producto que la organización puede encender o apagar para todo el mundo. Apagarlo NO borra nada: los datos siguen ahí y volver a encenderlo los devuelve tal cual. Los tres booleanos separan tres decisiones distintas: `available` es lo que concede el plan (o 3XA), `enabled` es el interruptor de la organización y `effective` es lo que el servidor aplica.",
        "required": [
          "key",
          "label",
          "description",
          "min_plan",
          "available",
          "enabled",
          "effective",
          "degrades"
        ],
        "properties": {
          "key": {
            "type": "string",
            "description": "Clave de la feature (la misma de FEATURE_MIN_PLAN, p. ej. `finance`)."
          },
          "label": {
            "type": "string",
            "description": "Nombre del módulo tal como se le enseña a la organización."
          },
          "description": {
            "type": "string",
            "description": "Qué deja de poder hacer la organización si lo apaga."
          },
          "min_plan": {
            "type": "string",
            "description": "Plan mínimo que incluye el módulo por defecto.",
            "enum": [
              "free",
              "equipo",
              "projekt"
            ]
          },
          "available": {
            "type": "boolean",
            "description": "Lo que el plan (o un override de 3XA) concede, IGNORANDO el interruptor de la organización. Hoy el listado solo devuelve módulos disponibles, así que siempre es `true`; viaja igualmente para poder distinguir «no lo tienes» (mejorar plan) de «lo has apagado tú» sin recomponer la regla en el cliente."
          },
          "enabled": {
            "type": "boolean",
            "description": "Interruptor de la organización. `false` = lo ha apagado ella."
          },
          "effective": {
            "type": "boolean",
            "description": "`available and enabled`: lo que el servidor aplica de verdad."
          },
          "degrades": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            },
            "description": "Módulos de este mismo catálogo que quedan DEGRADADOS si éste se apaga, indexados por su `key`, con el motivo. No se apagan en cascada ni se impide apagar: se avisa. Vacío si apagarlo no degrada nada."
          }
        }
      },
      "OrganizationModuleSet": {
        "type": "object",
        "title": "OrganizationModuleSet",
        "description": "Enciende (`true`) o apaga (`false`) el módulo indicado en la ruta para TODA la organización. Encender no CONCEDE nada: solo retira el apagado que puso la propia organización, así que nunca sirve para saltarse el plan — un módulo que el plan no incluye responde 422 `module_not_available` en los dos sentidos.",
        "required": [
          "enabled"
        ],
        "properties": {
          "enabled": {
            "type": "boolean",
            "description": "`true` enciende el módulo; `false` lo apaga."
          }
        }
      },
      "LoginCodeRequestResult": {
        "type": "object",
        "title": "LoginCodeRequestResult",
        "description": "Respuesta genérica a la solicitud de código. Es idéntica exista o no el email (anti-enumeración): no permite descubrir qué correos están registrados.",
        "required": [
          "message"
        ],
        "properties": {
          "message": {
            "type": "string",
            "description": "Mensaje neutro para mostrar al usuario.",
            "examples": [
              "Si el correo es válido, te hemos enviado un código de acceso."
            ]
          }
        }
      },
      "TwoFactorStatus": {
        "type": "object",
        "title": "TwoFactorStatus",
        "description": "Estado del segundo factor. `pending_setup` es un alta a medias: hay secreto guardado pero todavía NO protege ni bloquea nada.",
        "required": [
          "enabled",
          "pending_setup",
          "recovery_codes_remaining"
        ],
        "properties": {
          "enabled": {
            "type": "boolean"
          },
          "pending_setup": {
            "type": "boolean"
          },
          "confirmed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "recovery_codes_remaining": {
            "type": "integer",
            "description": "Códigos de recuperación sin usar que quedan."
          }
        }
      },
      "TwoFactorSetup": {
        "type": "object",
        "title": "TwoFactorSetup",
        "description": "Datos del alta. El secreto viaja UNA sola vez, aquí: no hay ningún endpoint que lo vuelva a mostrar, así que quien no lo guarde tendrá que reconfigurar.",
        "required": [
          "secret",
          "otpauth_uri"
        ],
        "properties": {
          "secret": {
            "type": "string",
            "description": "Secreto en base32, para introducirlo a mano si no se puede escanear el QR."
          },
          "otpauth_uri": {
            "type": "string",
            "description": "URI `otpauth://totp/…` que codifica el QR."
          }
        }
      },
      "TwoFactorCode": {
        "type": "object",
        "title": "TwoFactorCode",
        "description": "Un código de verificación. Vale tanto el TOTP de 6 dígitos de la app como uno de recuperación (`k7m2-9xqp`): el servidor prueba los dos, porque exigir de antemano cuál es dejaría fuera al otro justo cuando más falta hace.",
        "required": [
          "code"
        ],
        "properties": {
          "code": {
            "type": "string",
            "maxLength": 32
          }
        }
      },
      "RecoveryCodes": {
        "type": "object",
        "title": "RecoveryCodes",
        "description": "Códigos de recuperación EN CLARO. Solo se devuelven al emitirlos (activar o regenerar); después únicamente se guarda su hash.",
        "required": [
          "recovery_codes"
        ],
        "properties": {
          "recovery_codes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "WebAuthnOptions": {
        "type": "object",
        "title": "WebAuthnOptions",
        "description": "Opciones WebAuthn generadas por el servidor (begin de registro o de login). Se entregan tal cual a `navigator.credentials.create()/get()` en el navegador.",
        "required": [
          "options"
        ],
        "properties": {
          "options": {
            "type": "object",
            "additionalProperties": true,
            "description": "Objeto de opciones WebAuthn (challenge, rp, user/allowCredentials, …). El challenge se guarda además server-side (un solo uso, caduca en minutos)."
          }
        }
      },
      "StepUpMethods": {
        "type": "object",
        "title": "StepUpMethods",
        "description": "Factores disponibles para reconfirmar la identidad. `email` siempre `true` (fallback universal); `passkey`/`totp` según lo que el usuario tenga configurado. El modal ofrece el más fuerte disponible.",
        "required": [
          "passkey",
          "totp",
          "email"
        ],
        "properties": {
          "passkey": {
            "type": "boolean",
            "description": "Tiene al menos una passkey registrada."
          },
          "totp": {
            "type": "boolean",
            "description": "Tiene el segundo factor (TOTP) activo."
          },
          "email": {
            "type": "boolean",
            "description": "Puede recibir un código por email (siempre true)."
          }
        }
      },
      "StepUpResult": {
        "type": "object",
        "title": "StepUpResult",
        "description": "Confirmación realizada. El servidor ha adjuntado la cookie `step_up`; la acción sensible original puede reintentarse ya. `expires_in` = duración de la ventana.",
        "required": [
          "confirmed",
          "method",
          "expires_in"
        ],
        "properties": {
          "confirmed": {
            "type": "boolean"
          },
          "method": {
            "type": "string",
            "enum": [
              "passkey",
              "totp",
              "email"
            ],
            "description": "Factor con el que se confirmó."
          },
          "expires_in": {
            "type": "integer",
            "description": "Segundos que dura la ventana sudo de la confirmación."
          }
        }
      },
      "StepUpMessage": {
        "type": "object",
        "title": "StepUpMessage",
        "description": "Respuesta genérica (p. ej. tras pedir el código por email).",
        "required": [
          "message"
        ],
        "properties": {
          "message": {
            "type": "string"
          }
        }
      },
      "Passkey": {
        "type": "object",
        "title": "Passkey",
        "description": "Passkey (credencial WebAuthn) del usuario autenticado, en su representación enmascarada para gestión (listar / revocar).",
        "required": [
          "id",
          "name",
          "transports",
          "last_used_at",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b"
            ]
          },
          "name": {
            "type": "string",
            "description": "Nombre legible de la passkey (p. ej. el dispositivo).",
            "examples": [
              "MacBook Touch ID"
            ]
          },
          "transports": {
            "type": [
              "string",
              "null"
            ],
            "description": "Transportes soportados por el autenticador (CSV) o `null`.",
            "examples": [
              "internal,hybrid"
            ]
          },
          "last_used_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Último login con esta passkey; `null` si nunca se ha usado.",
            "examples": [
              "2026-07-09T10:30:00Z"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-09T09:00:00Z"
            ]
          }
        }
      },
      "LeaveRequest": {
        "type": "object",
        "title": "LeaveRequest",
        "required": [
          "id",
          "organization_id",
          "employee_id",
          "user_id",
          "leave_type",
          "start_date",
          "end_date",
          "days",
          "reason",
          "leave_status",
          "approver_id",
          "decided_at",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "employee_id": {
            "type": "string",
            "format": "uuid"
          },
          "user_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "leave_type": {
            "type": "string",
            "enum": [
              "vacation",
              "sick",
              "unpaid",
              "other"
            ]
          },
          "start_date": {
            "type": "string",
            "format": "date"
          },
          "end_date": {
            "type": "string",
            "format": "date"
          },
          "days": {
            "type": "number",
            "description": "Días laborables solicitados (calculado en servidor)."
          },
          "reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "leave_status": {
            "type": "string",
            "enum": [
              "pending",
              "approved",
              "rejected"
            ]
          },
          "approver_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "decided_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "LeaveBalance": {
        "type": "object",
        "title": "LeaveBalance",
        "required": [
          "employee_id",
          "year",
          "allowance",
          "taken",
          "pending",
          "remaining"
        ],
        "properties": {
          "employee_id": {
            "type": "string",
            "format": "uuid"
          },
          "year": {
            "type": "integer",
            "description": "Año del balance."
          },
          "allowance": {
            "type": "number",
            "description": "Días de vacaciones anuales que le corresponden al empleado: `department.annual_vacation_days` prorrateado por su `hire_date` (alta a mitad de año → parte proporcional; puede ser medio día, p. ej. 16.5). Si no hay departamento con asignación, se usa el valor por defecto igualmente prorrateado.",
            "examples": [
              22
            ]
          },
          "taken": {
            "type": "number",
            "description": "Días de vacaciones aprobados en el año."
          },
          "pending": {
            "type": "number",
            "description": "Días en solicitudes pendientes de aprobar."
          },
          "remaining": {
            "type": "number",
            "description": "Días restantes: allowance − taken."
          }
        }
      },
      "Absence": {
        "type": "object",
        "title": "Absence",
        "description": "Ausencia aprobada de un empleado (para el calendario de equipo).",
        "required": [
          "id",
          "organization_id",
          "employee_id",
          "leave_type",
          "start_date",
          "end_date",
          "days"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "employee_id": {
            "type": "string",
            "format": "uuid"
          },
          "leave_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "Tipo de ausencia, o `null` cuando quien consulta no puede saber POR QUÉ falta esa persona. Desde el 2026-09-10 el motivo solo viaja para manager+ o para la ausencia de uno mismo: «vacaciones» y «baja» no son la misma información —la segunda es un dato de salud— y este calendario es member+ a propósito, porque saber QUIÉN no está sí hace falta para organizar la semana. La clave viaja siempre; lo que cambia es si trae valor.",
            "enum": [
              "vacation",
              "sick",
              "unpaid",
              "other",
              null
            ]
          },
          "start_date": {
            "type": "string",
            "format": "date"
          },
          "end_date": {
            "type": "string",
            "format": "date"
          },
          "days": {
            "type": "number"
          }
        }
      },
      "TimesheetEntryRef": {
        "type": "object",
        "title": "TimesheetEntryRef",
        "description": "Referencia ligera a un time entry dentro de un dia de timesheet.",
        "required": [
          "id",
          "minutes",
          "description",
          "project_id",
          "task_id"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "minutes": {
            "type": "integer"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "task_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          }
        }
      },
      "TimesheetDay": {
        "type": "object",
        "title": "TimesheetDay",
        "description": "Horas imputadas en un dia de la semana de timesheet.",
        "required": [
          "entry_date",
          "total_minutes",
          "entries"
        ],
        "properties": {
          "entry_date": {
            "type": "string",
            "format": "date"
          },
          "total_minutes": {
            "type": "integer"
          },
          "entries": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TimesheetEntryRef"
            }
          }
        }
      },
      "TimesheetPeriod": {
        "type": "object",
        "title": "TimesheetPeriod",
        "description": "Semana de timesheet de un usuario (estado de aprobacion).",
        "required": [
          "id",
          "organization_id",
          "user_id",
          "week_start",
          "period_status",
          "submitted_at",
          "approved_at",
          "approver_id",
          "total_minutes",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "user_id": {
            "type": "string",
            "format": "uuid"
          },
          "week_start": {
            "type": "string",
            "format": "date",
            "description": "Lunes de la semana."
          },
          "period_status": {
            "type": "string",
            "enum": [
              "draft",
              "submitted",
              "approved",
              "rejected"
            ]
          },
          "submitted_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "approved_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "approver_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "total_minutes": {
            "type": "integer",
            "description": "Total de minutos imputados en la semana."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "TimesheetSubmitResult": {
        "type": "object",
        "title": "TimesheetSubmitResult",
        "description": "Respuesta del envio de un parte de horas: el periodo ya en `submitted` MAS a cuantos aprobadores ha salido el aviso. Es la unica respuesta del contrato que lleva `notified_approvers`, y por eso es un componente propio y no un campo opcional de `TimesheetPeriod`: en el listado o en la aprobacion seria una clave siempre nula que el SDK obligaria a comprobar.",
        "required": [
          "id",
          "organization_id",
          "user_id",
          "week_start",
          "period_status",
          "submitted_at",
          "approved_at",
          "approver_id",
          "total_minutes",
          "created_at",
          "updated_at",
          "notified_approvers"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "user_id": {
            "type": "string",
            "format": "uuid"
          },
          "week_start": {
            "type": "string",
            "format": "date",
            "description": "Primer dia de la semana enviada."
          },
          "period_status": {
            "type": "string",
            "enum": [
              "draft",
              "submitted",
              "approved",
              "rejected"
            ]
          },
          "submitted_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "approved_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "approver_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "total_minutes": {
            "type": "integer",
            "description": "Total de minutos imputados en la semana."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "notified_approvers": {
            "type": "integer",
            "description": "A cuantas personas ha salido el aviso de que hay un parte que aprobar: los owner/admin de la organizacion MENOS quien lo envia. `0` significa que no habia nadie mas a quien avisar (el caso de la organizacion de una sola persona), no que el envio fallara. Quien envia recibe siempre su acuse."
          }
        }
      },
      "TimesheetWeek": {
        "type": "object",
        "title": "TimesheetWeek",
        "description": "Vista semanal de timesheet — estado del periodo + tiempo imputado por dia.",
        "required": [
          "period",
          "week_start",
          "week_end",
          "days",
          "total_minutes"
        ],
        "properties": {
          "period": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/TimesheetPeriod"
              },
              {
                "type": "null"
              }
            ],
            "description": "Estado del periodo de aprobacion (null si no hay periodo creado aun)."
          },
          "week_start": {
            "type": "string",
            "format": "date"
          },
          "week_end": {
            "type": "string",
            "format": "date"
          },
          "days": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TimesheetDay"
            }
          },
          "total_minutes": {
            "type": "integer"
          }
        }
      },
      "TimesheetOverviewRow": {
        "type": "object",
        "title": "TimesheetOverviewRow",
        "description": "Fila de la vista de aprobaciones: una (usuario, semana) con horas imputadas y su estado de aprobacion. A diferencia de TimesheetPeriod, incluye semanas con horas imputadas que NUNCA se enviaron para aprobacion (`not_submitted`), para que un admin/owner (o manager en su ambito) vea TODAS las horas del equipo, no solo las solicitadas. `period_id` no es nulo solo cuando existe un periodo (habilita aprobar/rechazar sobre esa fila).",
        "required": [
          "user_id",
          "week_start",
          "total_minutes",
          "approval_status",
          "period_id",
          "submitted_at",
          "approved_at",
          "approver_id"
        ],
        "properties": {
          "user_id": {
            "type": "string",
            "format": "uuid",
            "description": "Usuario que imputo las horas de la semana."
          },
          "week_start": {
            "type": "string",
            "format": "date",
            "description": "Lunes de la semana."
          },
          "total_minutes": {
            "type": "integer",
            "description": "Total de minutos imputados en la semana por el usuario."
          },
          "approval_status": {
            "type": "string",
            "description": "Estado de aprobacion de la semana. `not_submitted` = hay horas imputadas pero la semana no se ha enviado para aprobacion (no existe periodo). El resto reflejan el estado del TimesheetPeriod.",
            "enum": [
              "not_submitted",
              "draft",
              "submitted",
              "approved",
              "rejected"
            ]
          },
          "period_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del TimesheetPeriod si la semana se llego a enviar; null si aun no existe periodo (`not_submitted`). Es el id necesario para aprobar/rechazar."
          },
          "submitted_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "approved_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "approver_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          }
        }
      },
      "TaskDependency": {
        "type": "object",
        "title": "TaskDependency",
        "description": "Relación de dependencia dirigida entre dos tareas del mismo proyecto.",
        "required": [
          "id",
          "organization_id",
          "project_id",
          "source_task_id",
          "target_task_id",
          "dep_type",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "project_id": {
            "type": "string",
            "format": "uuid"
          },
          "source_task_id": {
            "type": "string",
            "format": "uuid",
            "description": "Tarea que origina la dependencia."
          },
          "target_task_id": {
            "type": "string",
            "format": "uuid",
            "description": "Tarea a la que apunta la dependencia."
          },
          "dep_type": {
            "type": "string",
            "description": "Tipo de relación.",
            "enum": [
              "blocks",
              "blocked_by",
              "relates_to"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "SubtaskList": {
        "type": "object",
        "title": "SubtaskList",
        "description": "Respuesta de listado de subtareas con rollup total/done.",
        "required": [
          "subtasks",
          "progress"
        ],
        "properties": {
          "subtasks": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Task"
            }
          },
          "progress": {
            "type": "object",
            "required": [
              "total",
              "done"
            ],
            "properties": {
              "total": {
                "type": "integer",
                "description": "Total de subtareas directas."
              },
              "done": {
                "type": "integer",
                "description": "Subtareas con status `done`."
              }
            }
          }
        }
      },
      "MyWorkTask": {
        "type": "object",
        "title": "MyWorkTask",
        "description": "Tarea asignada al usuario en cualquier proyecto de la organización.",
        "required": [
          "id",
          "project_id",
          "project_name",
          "organization_id",
          "organization_name",
          "title",
          "status",
          "priority",
          "type",
          "due_date",
          "parent_id",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "project_id": {
            "type": "string",
            "format": "uuid"
          },
          "project_name": {
            "type": "string",
            "description": "Nombre del proyecto al que pertenece la tarea."
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_name": {
            "type": "string",
            "description": "Nombre de la organización a la que pertenece la tarea."
          },
          "reference": {
            "type": [
              "string",
              "null"
            ],
            "description": "Referencia JIRA-style `KEY-N` (clave del proyecto + número de tarea); `null` en tareas sin número.",
            "examples": [
              "PJKT-1686"
            ]
          },
          "title": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "description": "Estado de la tarea (los 4 base o un estado PERSONALIZADO de tablero, slug `^[a-z0-9_]+$`)."
          },
          "priority": {
            "type": "string",
            "enum": [
              "low",
              "medium",
              "high",
              "urgent"
            ]
          },
          "type": {
            "type": "string",
            "enum": [
              "epic",
              "story",
              "task",
              "bug",
              "spike",
              "chore"
            ]
          },
          "due_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID de la tarea padre; `null` si no es subtarea."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "MeWorkOrgCount": {
        "type": "object",
        "title": "MeWorkOrgCount",
        "description": "Número de tareas abiertas (no `done` ni `cancelled`) asignadas al usuario en una organización de la que es miembro. Alimenta los badges del selector de organización en \"Mi trabajo\".",
        "required": [
          "organization_id",
          "count"
        ],
        "properties": {
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID de la organización."
          },
          "count": {
            "type": "integer",
            "description": "Número de tareas abiertas asignadas al usuario en esa organización (excluye proyectos en papelera y estados cerrados)."
          }
        }
      },
      "SavedFilter": {
        "type": "object",
        "title": "SavedFilter",
        "description": "Filtro de tareas guardado por el usuario autenticado.",
        "required": [
          "id",
          "organization_id",
          "user_id",
          "name",
          "entity",
          "query",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "user_id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID del usuario propietario del filtro."
          },
          "name": {
            "type": "string",
            "description": "Nombre descriptivo del filtro."
          },
          "entity": {
            "type": "string",
            "description": "Superficie a la que aplica el filtro (`tasks`, `invoices`, `issues`…). El API lo exige al crear y lo devuelve siempre, y el contrato no lo declaraba NI EN EL CUERPO NI EN LA RESPUESTA (PJKT-2323): no es un enum cerrado, pero sin él el filtro no sabe a qué listado pertenece.",
            "examples": [
              "tasks"
            ]
          },
          "query": {
            "type": "object",
            "description": "Estado del filtro serializado (objeto libre).",
            "additionalProperties": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "DesktopState": {
        "type": "object",
        "title": "DesktopState",
        "description": "Estado del escritorio (ventanas, escritorios, orden del dock) de la persona autenticada en esta organización. Es SIEMPRE suyo: no hay forma de leer ni de escribir el de otra persona, ni siquiera siendo propietario de la organización — un escritorio no es un recurso de la organización que se administra, es dónde dejó cada uno sus cosas.",
        "required": [
          "layout",
          "version",
          "device_id",
          "updated_at"
        ],
        "properties": {
          "layout": {
            "type": [
              "string",
              "null"
            ],
            "description": "El escritorio serializado, tal cual lo escribió el cliente. `null` = esta persona no ha guardado nada todavía en esta organización, que es la respuesta normal la primera vez y NO un error: por eso la lectura contesta 200 con este campo a `null` y no un 404 que el cliente tendría que tratar como caso bueno."
          },
          "version": {
            "type": "integer",
            "minimum": 0,
            "description": "Cuántas veces se ha guardado. Empieza en `0` («no hay nada») y sube de uno en uno con cada escritura aceptada. Es el testigo del guardado condicional: se manda de vuelta en `base_version` al escribir, y si no coincide con el que hay, la escritura se rechaza con 409 en vez de pisar lo que otro dispositivo acababa de dejar."
          },
          "device_id": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 64,
            "description": "Qué dispositivo escribió lo último, con el identificador opaco que se inventa el propio cliente y guarda en su navegador. `null` mientras no haya nada guardado. Es lo que distingue «esto lo dejé yo aquí mismo» de «esto lo dejé en el portátil de casa»: sin él, un cliente que vuelve y encuentra un layout distinto del suyo no puede saber si es su propio guardado de hace un rato o el escritorio vivo de otro sitio, y las dos cosas piden respuestas contrarias. NO es un dato de inventario: no lleva marca, modelo ni sistema, y el servidor no lo relaciona con nada — solo lo compara consigo mismo.",
            "examples": [
              "9f1c2b7e-3a44-4c0d-9d1a-6b0f2e5c8a31"
            ]
          },
          "updated_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cuándo se guardó por última vez; `null` si nunca."
          }
        }
      },
      "DesktopStateSave": {
        "type": "object",
        "title": "DesktopStateSave",
        "description": "Guardado CONDICIONAL del escritorio. Los tres campos son obligatorios porque los tres hacen falta para que el guardado sea seguro; con `base_version` opcional, quien lo omitiera estaría pidiendo «pisa lo que haya», que es justamente el comportamiento que este recurso existe para quitar.",
        "required": [
          "layout",
          "device_id",
          "base_version"
        ],
        "properties": {
          "layout": {
            "type": "string",
            "maxLength": 65536,
            "description": "El escritorio serializado. El servidor no lo interpreta (ver `DesktopState`). El tope son 64 KiB, el mismo que ya tenían los ajustes del escritorio en `os_preferences`: lo que pasa de ahí no es un escritorio, es una fuga."
          },
          "device_id": {
            "type": "string",
            "minLength": 1,
            "maxLength": 64,
            "description": "Identificador opaco del dispositivo que escribe, estable en ese navegador. Lo genera el cliente; el servidor solo lo guarda y lo devuelve.",
            "examples": [
              "9f1c2b7e-3a44-4c0d-9d1a-6b0f2e5c8a31"
            ]
          },
          "base_version": {
            "type": "integer",
            "minimum": 0,
            "description": "La `version` que el cliente cree que hay guardada — la que leyó, o la que le devolvió su última escritura. Si coincide, la escritura pasa y la versión sube a `base_version + 1`; si no, se rechaza con **409 `desktop_state_stale`** y no se toca nada. El cliente que reciba ese 409 relee el estado y PREGUNTA a la persona («Retomar» / «Seguir aquí») en vez de decidir por ella: los dos escritorios son de verdad y el sistema no tiene con qué elegir entre ellos. Para escribir el primero se manda `0`."
          }
        }
      },
      "Reminder": {
        "type": "object",
        "title": "Reminder",
        "description": "Recordatorio personal dentro de una organización. Es SIEMPRE de quien lo creó: el API no deja crear uno a nombre de otra persona ni verlo desde otra cuenta, ni siquiera siendo admin — para encargarle algo a alguien está la tarea de Proyectos, que tiene responsable, estado y comentarios.",
        "required": [
          "id",
          "organization_id",
          "user_id",
          "list_id",
          "text",
          "remind_at",
          "location",
          "notes",
          "done",
          "done_at",
          "archived_at",
          "task_id",
          "client_id",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "user_id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID de la persona a la que pertenece el recordatorio (siempre quien lo creó)."
          },
          "list_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Lista de la que cuelga, o `null` = BANDEJA DE ENTRADA. La bandeja no es una fila: es la ausencia de lista, y por eso el `+` de la barra puede crear un recordatorio sin haber elegido nada y sin que el servidor tenga que sembrar una lista «por defecto» la primera vez (una siembra perezosa que dos pestañas a la vez duplicarían). La lista tiene que ser TUYA y de esta organización; si no, 422. Si se archiva la lista, el recordatorio no se mueve; si se BORRA, cae a la bandeja (`null`) en vez de desaparecer."
          },
          "text": {
            "type": "string",
            "maxLength": 500,
            "description": "Qué hay que recordar, en una línea.",
            "examples": [
              "Llamar a Marta antes de mandar el presupuesto"
            ]
          },
          "remind_at": {
            "type": "string",
            "format": "date-time",
            "description": "Momento del aviso, en UTC. Es obligatorio: un recordatorio sin cuándo es una nota, y para notas está Documentos."
          },
          "location": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 200,
            "description": "Dónde, en texto libre («en la oficina», «Calle Mayor 3»). NO hay latitud y longitud a propósito: sin un mapa que las pinte ni un geofence que las dispare, serían dos columnas que nadie rellena — y un campo vacío en todas las filas es peor que no tenerlo, porque la pantalla tiene que reservarle sitio igual. El día que haya mapa, se añaden y este texto se queda.",
            "examples": [
              "Notaría de la Plaza Mayor"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 2000,
            "description": "Notas del recordatorio: los detalles que no caben en el renglón del `text`. Tope 2000 y no ilimitado por la misma razón que `text` es de 500 — lo que pasa de aquí es un documento, y para eso está Documentos."
          },
          "done": {
            "type": "boolean",
            "description": "¿Ya está hecho? Los pendientes se listan primero."
          },
          "done_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cuándo se marcó hecho; `null` mientras está pendiente. Lo pone el servidor al cambiar `done` a `true` y lo borra al reabrirlo — el cliente no lo manda."
          },
          "archived_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cuándo se archivó; `null` mientras está a la vista. Archivar es una MARCA y no un borrado: el listado por defecto no los trae, pero la fila sigue ahí y desarchivar la devuelve entera. Lo sella el servidor al mandar `archived: true`, como `done_at`."
          },
          "task_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Tarea de la que cuelga, si cuelga de alguna. Tiene que ser de la MISMA organización (si no, 422); si la tarea se borra, el recordatorio sobrevive con `null` — el aviso sigue siendo válido aunque la tarea ya no esté."
          },
          "client_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Cliente del que cuelga, si cuelga de alguno. Mismas dos reglas que `task_id`: misma organización, y `null` si el cliente desaparece."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ReminderCreate": {
        "type": "object",
        "title": "ReminderCreate",
        "description": "Datos para crear un recordatorio a nombre del usuario autenticado.",
        "required": [
          "text",
          "remind_at"
        ],
        "properties": {
          "text": {
            "type": "string",
            "minLength": 1,
            "maxLength": 500,
            "description": "Qué hay que recordar. Se recorta por los extremos; en blanco es 422."
          },
          "remind_at": {
            "type": "string",
            "format": "date-time",
            "description": "Momento del aviso (UTC). Obligatorio."
          },
          "list_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Lista TUYA de esta organización de la que cuelga. Omitirlo o mandar `null` lo deja en la bandeja de entrada, que es lo que hace el `+` de la barra cuando no se ha elegido lista. Una lista de otra persona o de otra organización es 422."
          },
          "location": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 200,
            "description": "Dónde, en texto libre (opcional). Se recorta; en blanco se guarda `null`."
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 2000,
            "description": "Notas (opcional). Se recorta; en blanco se guarda `null`."
          },
          "task_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Tarea de la misma organización de la que cuelga (opcional)."
          },
          "client_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Cliente de la misma organización del que cuelga (opcional)."
          }
        },
        "additionalProperties": false
      },
      "ReminderUpdate": {
        "type": "object",
        "title": "ReminderUpdate",
        "description": "Campos a cambiar de un recordatorio. En `text`, `remind_at` y `done`, omitir o mandar `null` deja el valor como está; en `list_id`, `location`, `notes`, `task_id` y `client_id` un `null` explícito desengancha o vacía. `archived` archiva o desarchiva.",
        "properties": {
          "text": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 1,
            "maxLength": 500,
            "description": "Nuevo texto (1-500 chars, se recorta por los extremos). Omitir o `null` = sin cambio."
          },
          "remind_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Nuevo momento del aviso (UTC). Omitir o `null` = sin cambio."
          },
          "done": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "`true` lo marca hecho y sella `done_at`; `false` lo reabre y devuelve `done_at` a `null`. Omitir o `null` = sin cambio."
          },
          "archived": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "`true` lo archiva y sella `archived_at`; `false` lo desarchiva y devuelve `archived_at` a `null`. Omitir o `null` = sin cambio. Archivar y marcar hecho son cosas DISTINTAS y se pueden combinar: un recordatorio hecho sigue en la lista de hechos hasta que además se archiva."
          },
          "list_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Lista TUYA de esta organización a la que moverlo, o `null` para devolverlo a la bandeja de entrada. Una lista ajena es 422."
          },
          "location": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 200,
            "description": "Nueva ubicación en texto libre, o `null` para borrarla."
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 2000,
            "description": "Nuevas notas, o `null` para borrarlas."
          },
          "task_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Tarea de la misma organización, o `null` para desenganchar."
          },
          "client_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Cliente de la misma organización, o `null` para desenganchar."
          }
        },
        "additionalProperties": false
      },
      "ReminderList": {
        "type": "object",
        "title": "ReminderList",
        "description": "Lista personal de recordatorios dentro de una organización. Es SIEMPRE de quien la creó: el API no deja verla ni tocarla desde otra cuenta, ni siquiera siendo admin. Un recordatorio sin lista NO es un error — cae en la bandeja de entrada, que no es una fila sino la ausencia de `list_id`.",
        "required": [
          "id",
          "organization_id",
          "user_id",
          "name",
          "color",
          "icon",
          "position",
          "archived_at",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "user_id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID de la persona dueña de la lista (siempre quien la creó)."
          },
          "name": {
            "type": "string",
            "maxLength": 60,
            "description": "Nombre de la lista. NO es único a propósito: archivar «Casa» y crear otra «Casa» tiene que poder hacerse, o archivar pasaría a ser un borrado con otro nombre — el usuario perdería el nombre además de la lista.",
            "examples": [
              "Casa",
              "Recados"
            ]
          },
          "color": {
            "type": "string",
            "maxLength": 20,
            "description": "Color de la lista: hex (`#rgb`/`#rrggbb`) o nombre de token (p. ej. `coral`). Mismo formato que el `color` de una etiqueta, para que la interfaz no tenga que saber dos.",
            "examples": [
              "#fd2554"
            ]
          },
          "icon": {
            "type": "string",
            "maxLength": 40,
            "description": "Nombre del icono Lucide con el que se pinta la lista, igual que el `icono` de una app del catálogo. Se guarda el NOMBRE y no un SVG: un SVG en la base es marcado que nadie revisa camino de un `dangerouslySetInnerHTML`.",
            "examples": [
              "House",
              "ShoppingCart"
            ]
          },
          "position": {
            "type": "integer",
            "minimum": 0,
            "description": "Orden de presentación dentro de las listas de esta persona (menor primero). Lo asigna el servidor al crear (al final) y se cambia con el PATCH."
          },
          "archived_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cuándo se archivó; `null` mientras está activa. Archivar una lista NO borra sus recordatorios ni los mueve: siguen colgando de ella y vuelven a verse al desarchivarla."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ReminderListCreate": {
        "type": "object",
        "title": "ReminderListCreate",
        "description": "Datos para crear una lista de recordatorios a nombre del usuario autenticado.",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 60,
            "description": "Nombre de la lista. Se recorta por los extremos; en blanco es 422."
          },
          "color": {
            "type": "string",
            "minLength": 1,
            "maxLength": 20,
            "default": "coral",
            "description": "Color hex (`#rgb`/`#rrggbb`) o nombre de token. Si se omite, el servidor pone el coral de la casa — una lista sin color se pintaría con un hueco."
          },
          "icon": {
            "type": "string",
            "minLength": 1,
            "maxLength": 40,
            "default": "List",
            "description": "Nombre del icono Lucide. Si se omite, el servidor pone `List`: el mismo motivo que el color, y así la interfaz nunca recibe `null` que pintar."
          }
        },
        "additionalProperties": false
      },
      "ReminderListUpdate": {
        "type": "object",
        "title": "ReminderListUpdate",
        "description": "Campos a cambiar de una lista. En `name`, `color`, `icon` y `position`, omitir o mandar `null` deja el valor como está. `archived: true` la archiva (sella `archived_at` en el servidor) y `false` la desarchiva.",
        "properties": {
          "name": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 1,
            "maxLength": 60,
            "description": "Nuevo nombre (1-60 chars, se recorta). Omitir o `null` = sin cambio."
          },
          "color": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 1,
            "maxLength": 20,
            "description": "Nuevo color hex o token. Omitir o `null` = sin cambio; en BLANCO es 422, no «sin cambio» en silencio — la columna es NOT NULL, así que dejarla a nada no se puede cumplir, y tragárselo dejaría al usuario viendo el color viejo tras un 200 y creyendo que se ignoró por otro motivo."
          },
          "icon": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 1,
            "maxLength": 40,
            "description": "Nuevo icono Lucide. Omitir o `null` = sin cambio; en blanco, 422."
          },
          "position": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "description": "Nueva posición en el orden de la persona. Omitir o `null` = sin cambio. El servidor NO recoloca las demás: dos listas pueden empatar y el desempate lo cierra el `id`, para que reordenar no sea una escritura sobre toda la tabla."
          },
          "archived": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "`true` archiva la lista y sella `archived_at`; `false` la desarchiva y lo devuelve a `null`. Omitir o `null` = sin cambio. Archivar NO toca los recordatorios de dentro."
          }
        },
        "additionalProperties": false
      },
      "Note": {
        "type": "object",
        "title": "Note",
        "description": "Nota personal dentro de una organización, tal como aparece en el listado: título y vista previa DERIVADOS del cuerpo, más sus marcas y sus fechas. El cuerpo entero se pide al abrirla.",
        "required": [
          "id",
          "organization_id",
          "user_id",
          "title",
          "preview",
          "pinned_at",
          "archived_at",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "user_id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID de la persona a la que pertenece la nota (siempre quien la creó)."
          },
          "title": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 120,
            "description": "Título DERIVADO: la primera línea no vacía del cuerpo, sin sus almohadillas de encabezado markdown y recortada a 120 caracteres. No es una columna y no se puede escribir — cambiar la primera línea del cuerpo es cambiar el título, que es como funciona Notas de Apple y por lo que no hay campo que rellenar al crear.\n\nEs `null` cuando la nota está VACÍA (recién creada con el `+`, todavía sin teclear nada), y el servidor no inventa un «Nueva nota» a propósito: sería castellano metido en el dato, y este producto se sirve en dos idiomas. Cómo se llama una nota en blanco lo decide quien la pinta.\n\nTampoco lleva puntos suspensivos cuando se corta en 120: el recorte visual es de la pantalla (CSS), y un carácter que la persona no escribió aparecería en cualquier copia del título."
          },
          "preview": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 200,
            "description": "Vista previa DERIVADA: lo que sigue al renglón del título, con los saltos y los espacios colapsados y recortado a 200 caracteres. `null` si la nota es solo su primera línea (o está vacía). Es lo que hace útil un listado sin cuerpos: dos renglones bastan para reconocer la nota que se busca."
          },
          "pinned_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cuándo se fijó arriba; `null` si no está fijada. Es una MARCA CON FECHA y no un booleano, por lo mismo que `archived_at` en un recordatorio: es reversible, se sabe desde cuándo, y el día que haya que ordenar las fijadas entre sí el dato ya está. Las fijadas salen primero en el listado, y dentro de cada grupo manda `updated_at`."
          },
          "archived_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cuándo se archivó; `null` mientras está a la vista. Archivar es una marca, nunca un borrado: el listado por defecto no las trae, pero la fila sigue entera y desarchivar la devuelve. Borrar existe aparte y es definitivo."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "Última vez que CAMBIÓ el contenido o una marca. Es el orden del listado (la más tocada arriba), así que un PATCH que no cambia nada NO lo mueve: si lo moviera, cada latido del autoguardado reordenaría la lista bajo el cursor de quien está escribiendo."
          }
        }
      },
      "NoteDetail": {
        "title": "NoteDetail",
        "description": "Nota personal con su cuerpo en markdown. Lo devuelven el alta, el detalle y la actualización; el listado devuelve la versión sin cuerpo.",
        "allOf": [
          {
            "$ref": "#/components/schemas/Note"
          },
          {
            "type": "object",
            "required": [
              "body"
            ],
            "properties": {
              "body": {
                "type": "string",
                "maxLength": 50000,
                "description": "El cuerpo, en MARKDOWN PLANO. Markdown y no un editor rico con bloques porque un editor rico es Documentos otra vez: allí el contenido es de la organización, se comenta, se versiona y se publica, y el producto ya tiene su renderizador (`components/docs/markdown.tsx`, react-markdown + remark-gfm, sin HTML crudo) y guarda ese markdown como TEXTO. Aquí se guarda igual, así que una nota se puede leer, buscar y pegar en cualquier sitio sin pasar por un parser propio.\n\nPuede venir VACÍO (`\"\"`), y es un estado legítimo: el `+` crea la nota antes de que nadie teclee: ésa es la app. Nunca es `null` — una columna con dos formas de decir «no hay texto» obliga a comprobar las dos en cada pantalla, hasta el día que alguien comprueba una.\n\nNO se recorta por los extremos, al revés que el `text` de un recordatorio. Un recordatorio es un rótulo de un renglón; una nota es un texto que se está escribiendo, y el autoguardado dispara mientras el cursor está en la línea en blanco que la persona acaba de abrir: devolverle el cuerpo sin ese salto le movería el cursor cada dos segundos.\n\nEl tope de 50.000 no es un límite de la columna (es MEDIUMTEXT, como el de un documento): es lo que impide que un pegado accidental convierta una nota en un fichero. Lo que pasa de ahí es un documento, y Documentos ya existe."
              }
            }
          }
        ]
      },
      "NoteCreate": {
        "type": "object",
        "title": "NoteCreate",
        "description": "Datos para crear una nota a nombre del usuario autenticado.",
        "properties": {
          "body": {
            "type": "string",
            "maxLength": 50000,
            "default": "",
            "description": "El cuerpo en markdown. Omitirlo crea una nota VACÍA, que es justo lo que manda el `+` de la barra: se abre el editor y se escribe dentro, con el autoguardado haciendo el resto. Sin esto, el `+` tendría que esperar a que la persona teclease algo antes de poder crear nada — y entonces el borrador viviría en el navegador hasta el primer guardado, que es donde se pierden las notas."
          }
        },
        "additionalProperties": false
      },
      "NoteUpdate": {
        "type": "object",
        "title": "NoteUpdate",
        "description": "Campos a cambiar de una nota. Todo es opcional y omitir o mandar `null` deja el valor como está; `pinned` y `archived` son órdenes, y sus sellos los pone el servidor.",
        "properties": {
          "body": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 50000,
            "description": "Nuevo cuerpo en markdown, tal cual (no se recorta: ver `NoteDetail.body`). El vacío (`\"\"`) es válido y VACÍA la nota — que es lo que pasa cuando alguien borra todo lo que había escrito, y tragárselo dejaría la nota diciendo lo contrario de lo que se ve en pantalla. Omitir o `null` = sin cambio; para vaciarla se manda `\"\"`."
          },
          "pinned": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "`true` la fija arriba y sella `pinned_at`; `false` la suelta y lo devuelve a `null`. Omitir o `null` = sin cambio. Repetir la misma orden NO mueve el sello: fijar dos veces no vuelve a fijarla."
          },
          "archived": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "`true` la archiva y sella `archived_at`; `false` la desarchiva. Omitir o `null` = sin cambio. Es ORTOGONAL a `pinned`: archivar una nota fijada la saca del listado sin soltarla, y desarchivarla la devuelve arriba donde estaba — el pin es lo que la persona decidió, no un efecto del archivo."
          }
        },
        "additionalProperties": false
      },
      "BoardColumn": {
        "type": "object",
        "title": "BoardColumn",
        "description": "Columna configurable del tablero Kanban de un proyecto.",
        "required": [
          "id",
          "organization_id",
          "project_id",
          "name",
          "status_key",
          "category",
          "color",
          "position",
          "wip_limit",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID de la columna. Las columnas por defecto (cuando el proyecto no tiene columnas configuradas) usan UUIDs centinela de la forma `00000000-0000-0000-0000-00000000000{n}`."
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "project_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "description": "Nombre visible de la columna en el tablero."
          },
          "status_key": {
            "type": "string",
            "pattern": "^[a-z0-9_]+$",
            "maxLength": 20,
            "description": "Clave de status que mapea esta columna. Para las 4 columnas base coincide con un valor de TaskStatus (todo/in_progress/done/cancelled); para estados PERSONALIZADOS es un slug propio (p.ej. `en_revision`)."
          },
          "category": {
            "type": "string",
            "enum": [
              "todo",
              "in_progress",
              "in_review",
              "blocked",
              "done",
              "cancelled"
            ],
            "description": "Semántica de la columna para workflow + time-tracking. `done`/`cancelled` son terminales (mover a ellas siempre se permite); a una columna `blocked` también se puede entrar y salir desde cualquier posición, porque no es un paso del flujo. Tiempo imputable: solo `todo` e `in_progress` acumulan; `in_review` y `blocked` NO, porque en ambos la tarea está esperando a otro, y `done`/`cancelled` paran el reloj."
          },
          "color": {
            "type": [
              "string",
              "null"
            ],
            "description": "Color hex opcional del chip de la columna (p.ej. `#8b5cf6`)."
          },
          "position": {
            "type": "integer",
            "description": "Posición 0-based de la columna en el tablero."
          },
          "wip_limit": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Límite de tareas en progreso simultáneo; `null` = sin límite."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Board": {
        "type": "object",
        "title": "Board",
        "description": "Tablero completo de un proyecto en una única respuesta: las columnas (persistidas o por defecto) con sus tareas agrupadas por `status_key`, contadores por columna y señal `over_wip`. Sustituye a las N llamadas que hoy arma el cliente (columnas + tareas por separado).",
        "required": [
          "swimlane",
          "columns"
        ],
        "properties": {
          "swimlane": {
            "type": "string",
            "enum": [
              "none",
              "assignee",
              "priority",
              "type"
            ],
            "description": "Criterio de swimlane aplicado (por defecto `none`)."
          },
          "columns": {
            "type": "array",
            "description": "Columnas del tablero en orden de posición, cada una con sus tareas.",
            "items": {
              "$ref": "#/components/schemas/BoardColumnGroup"
            }
          }
        }
      },
      "BoardColumnGroup": {
        "type": "object",
        "title": "BoardColumnGroup",
        "description": "Una columna del tablero junto con las tareas cuyo `status` coincide con el `status_key` de la columna. `tasks` es siempre la lista plana completa de la columna; `swimlanes` solo se rellena cuando el board se pide con `swimlane` distinto de `none` (partición de esas mismas tareas por el criterio).",
        "required": [
          "column",
          "task_count",
          "over_wip",
          "tasks",
          "swimlanes"
        ],
        "properties": {
          "column": {
            "$ref": "#/components/schemas/BoardColumn"
          },
          "task_count": {
            "type": "integer",
            "description": "Número total de tareas en la columna."
          },
          "over_wip": {
            "type": "boolean",
            "description": "`true` cuando la columna tiene `wip_limit` y `task_count` lo supera. Señal de que la columna está por encima de su límite de trabajo en curso."
          },
          "tasks": {
            "type": "array",
            "description": "Tareas de la columna (lista plana, orden estable por antigüedad).",
            "items": {
              "$ref": "#/components/schemas/Task"
            }
          },
          "swimlanes": {
            "type": "array",
            "description": "Carriles de la columna cuando `swimlane != none` (vacío en modo `none`). Particiona las mismas tareas de `tasks`.",
            "items": {
              "$ref": "#/components/schemas/BoardSwimlaneGroup"
            }
          }
        }
      },
      "BoardSwimlaneGroup": {
        "type": "object",
        "title": "BoardSwimlaneGroup",
        "description": "Grupo de tareas dentro de una columna, particionadas por el criterio de swimlane solicitado (assignee/priority/type). Solo se rellena cuando el board se pide con `swimlane` distinto de `none`.",
        "required": [
          "key",
          "label",
          "task_count",
          "tasks"
        ],
        "properties": {
          "key": {
            "type": "string",
            "description": "Clave estable del carril: UUID del asignado (o `unassigned`) para `assignee`; el valor de prioridad/tipo para `priority`/`type`."
          },
          "label": {
            "type": "string",
            "description": "Etiqueta legible del carril (nombre del asignado, prioridad o tipo)."
          },
          "task_count": {
            "type": "integer",
            "description": "Número de tareas en este carril."
          },
          "tasks": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Task"
            }
          }
        }
      },
      "TaskBulkResult": {
        "type": "object",
        "title": "TaskBulkResult",
        "description": "Resultado por-elemento de una operación en lote sobre tareas. `updated` son las tareas que cambiaron efectivamente (o se eliminaron); `skipped` las que ya estaban en el estado destino (no-op idempotente); `errors` las que no se pudieron aplicar, con el motivo por id. La operación es transaccional: un único commit al final si hubo algún cambio real.",
        "required": [
          "updated",
          "skipped",
          "errors"
        ],
        "properties": {
          "updated": {
            "type": "integer",
            "description": "Número de tareas que cambiaron efectivamente (o se eliminaron).",
            "examples": [
              3
            ]
          },
          "skipped": {
            "type": "integer",
            "description": "Tareas que ya estaban en el valor destino (no-op).",
            "examples": [
              1
            ]
          },
          "errors": {
            "type": "array",
            "description": "Tareas no aplicadas, con el motivo por id.",
            "items": {
              "type": "object",
              "required": [
                "id",
                "reason"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid"
                },
                "reason": {
                  "type": "string",
                  "description": "Motivo por el que esa tarea no se aplicó. `not_found` = inexistente o de otra organización/proyecto. `would_create_cycle` = solo en `op=set_parent`: colgarla de ese padre la haría descendiente de sí misma. Sin `enum` a propósito: el conjunto crece con cada operación que pueda fallar por tarea y un cliente no debe romperse por un motivo nuevo; trátalo como texto y muestra el que llegue.",
                  "examples": [
                    "not_found"
                  ]
                }
              }
            }
          }
        }
      },
      "SlaPolicy": {
        "type": "object",
        "title": "SlaPolicy",
        "description": "Compromiso de servicio de una organización (objetivo, alcance y condiciones del reloj).",
        "required": [
          "id",
          "organization_id",
          "name",
          "target_kind",
          "target_minutes",
          "project_id",
          "priority",
          "task_type",
          "start_categories",
          "stop_categories",
          "extra_paused_categories",
          "warn_at_percent",
          "active",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "maxLength": 120,
            "examples": [
              "Resolución urgente 4 h"
            ]
          },
          "target_kind": {
            "type": "string",
            "description": "Qué se mide. `first_response`: lo que se tarda en hacerse cargo (salir de la cola). `resolution`: lo que se tarda en cerrar.",
            "enum": [
              "first_response",
              "resolution"
            ]
          },
          "target_minutes": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100000,
            "description": "Minutos LABORABLES del compromiso: se cuentan sobre la jornada, los festivos y la zona horaria de la organización, no sobre horas de reloj (4 h = 240; dos días de 8 h = 960).",
            "examples": [
              240
            ]
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Proyecto al que se limita; `null` = cualquiera. Es como se firma un compromiso POR CLIENTE."
          },
          "priority": {
            "type": [
              "string",
              "null"
            ],
            "description": "Prioridad a la que se aplica; `null` = cualquiera.",
            "enum": [
              "low",
              "medium",
              "high",
              "urgent",
              null
            ]
          },
          "task_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "Tipo de tarea al que se aplica; `null` = cualquiera.",
            "enum": [
              "epic",
              "story",
              "task",
              "bug",
              "spike",
              "chore",
              null
            ]
          },
          "start_categories": {
            "type": "array",
            "description": "Categorías de columna cuya ENTRADA arranca el reloj. Se devuelven siempre resueltas (si no se configuraron, las de por defecto del objetivo).",
            "items": {
              "type": "string",
              "enum": [
                "todo",
                "in_progress",
                "in_review",
                "blocked",
                "done",
                "cancelled"
              ]
            },
            "examples": [
              [
                "todo",
                "in_progress"
              ]
            ]
          },
          "stop_categories": {
            "type": "array",
            "description": "Categorías cuya ENTRADA para el reloj definitivamente. La parada no se revierte: reabrir la tarea no reabre el compromiso ya medido.",
            "items": {
              "type": "string",
              "enum": [
                "todo",
                "in_progress",
                "in_review",
                "blocked",
                "done",
                "cancelled"
              ]
            },
            "examples": [
              [
                "done",
                "cancelled"
              ]
            ]
          },
          "extra_paused_categories": {
            "type": "array",
            "description": "Pausa ADICIONAL. «En revisión» y «bloqueada» ya paran siempre (regla común del producto: ahí la tarea está esperando a otro); aquí solo se pueden AÑADIR categorías que hoy sí consumen — en la práctica `todo` —, nunca quitar las que ya paran.",
            "items": {
              "type": "string",
              "enum": [
                "todo",
                "in_progress"
              ]
            },
            "examples": [
              [
                "todo"
              ]
            ]
          },
          "warn_at_percent": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "maximum": 99,
            "description": "Porcentaje del objetivo CONSUMIDO a partir del cual se avisa de que la tarea va camino de incumplir; `null` = el valor por defecto de la plataforma (80 %). Se expresa en porcentaje y no en minutos para que signifique lo mismo en un compromiso de 1 h y en uno de cinco días. El aviso llega por el sistema de notificaciones (categoría `sla`) y se emite UNA sola vez por reloj y umbral.",
            "examples": [
              80
            ]
          },
          "active": {
            "type": "boolean",
            "description": "Una política inactiva deja de aplicarse a partir de ese momento; los relojes ya cerrados no cambian."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "SlaPolicyCreateIn": {
        "type": "object",
        "title": "SlaPolicyCreateIn",
        "required": [
          "name",
          "target_kind",
          "target_minutes"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 120
          },
          "target_kind": {
            "type": "string",
            "enum": [
              "first_response",
              "resolution"
            ]
          },
          "target_minutes": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100000,
            "description": "Minutos LABORABLES del compromiso (jornada + festivos + zona horaria de la organización)."
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Debe ser un proyecto de ESTA organización (422 `invalid_project` si no)."
          },
          "priority": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "low",
              "medium",
              "high",
              "urgent",
              null
            ]
          },
          "task_type": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "epic",
              "story",
              "task",
              "bug",
              "spike",
              "chore",
              null
            ]
          },
          "start_categories": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string",
              "enum": [
                "todo",
                "in_progress",
                "in_review",
                "blocked",
                "done",
                "cancelled"
              ]
            }
          },
          "stop_categories": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string",
              "enum": [
                "todo",
                "in_progress",
                "in_review",
                "blocked",
                "done",
                "cancelled"
              ]
            }
          },
          "extra_paused_categories": {
            "type": [
              "array",
              "null"
            ],
            "description": "Solo admite categorías que hoy consumen tiempo; las que ya paran («en revisión», «bloqueada») y las terminales se rechazan con 422, porque pedirlas significaría no haber entendido la regla.",
            "items": {
              "type": "string",
              "enum": [
                "todo",
                "in_progress"
              ]
            }
          },
          "warn_at_percent": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "maximum": 99,
            "description": "Porcentaje del objetivo consumido al que avisar de riesgo de incumplimiento; omitido o `null` = el default de la plataforma (80 %)."
          },
          "active": {
            "type": "boolean",
            "description": "Por defecto: true."
          }
        }
      },
      "SlaPolicyUpdateIn": {
        "type": "object",
        "title": "SlaPolicyUpdateIn",
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 120
          },
          "target_minutes": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100000
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "`null` ensancha la política a cualquier proyecto."
          },
          "priority": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "low",
              "medium",
              "high",
              "urgent",
              null
            ]
          },
          "task_type": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "epic",
              "story",
              "task",
              "bug",
              "spike",
              "chore",
              null
            ]
          },
          "start_categories": {
            "type": [
              "array",
              "null"
            ],
            "description": "`null` vuelve a las condiciones por defecto del objetivo.",
            "items": {
              "type": "string",
              "enum": [
                "todo",
                "in_progress",
                "in_review",
                "blocked",
                "done",
                "cancelled"
              ]
            }
          },
          "stop_categories": {
            "type": [
              "array",
              "null"
            ],
            "description": "`null` vuelve a las condiciones por defecto del objetivo.",
            "items": {
              "type": "string",
              "enum": [
                "todo",
                "in_progress",
                "in_review",
                "blocked",
                "done",
                "cancelled"
              ]
            }
          },
          "extra_paused_categories": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string",
              "enum": [
                "todo",
                "in_progress"
              ]
            }
          },
          "warn_at_percent": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "maximum": 99,
            "description": "`null` vuelve al default de la plataforma (80 %)."
          },
          "active": {
            "type": "boolean"
          }
        }
      },
      "SlaPauseSpan": {
        "type": "object",
        "title": "SlaPauseSpan",
        "description": "Periodo en el que el reloj no corrió (la tarea estaba esperando a otro).",
        "required": [
          "category",
          "started_at",
          "ended_at"
        ],
        "properties": {
          "category": {
            "type": "string",
            "description": "Categoría de columna en la que estuvo parada (`in_review`, `blocked`…)."
          },
          "started_at": {
            "type": "string",
            "format": "date-time"
          },
          "ended_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "`null` si la tarea sigue en ese estado ahora mismo."
          }
        }
      },
      "TaskSlaClock": {
        "type": "object",
        "title": "TaskSlaClock",
        "description": "Medición de un compromiso de servicio sobre una tarea.",
        "required": [
          "id",
          "task_id",
          "policy_id",
          "policy_name",
          "target_kind",
          "target_minutes",
          "state",
          "started_at",
          "stopped_at",
          "due_at",
          "consumed_minutes",
          "remaining_minutes",
          "elapsed_at",
          "breached",
          "breached_at",
          "closed_at",
          "based_on_reconstructed",
          "pauses",
          "calendar_snapshot"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "task_id": {
            "type": "string",
            "format": "uuid"
          },
          "policy_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Política que lo mide; `null` si se borró después de cerrarse el reloj. La fila sobrevive a propósito: borrar el compromiso no borra lo que ya se midió con él."
          },
          "policy_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nombre de la política tal y como estaba al calcular (copia congelada)."
          },
          "target_kind": {
            "type": "string",
            "enum": [
              "first_response",
              "resolution"
            ]
          },
          "target_minutes": {
            "type": "integer",
            "description": "Minutos laborables comprometidos."
          },
          "state": {
            "type": "string",
            "description": "`pending` (aún no arrancó), `running` (corriendo), `paused` (la tarea espera a otro), `met` / `breached` (parado, y por tanto definitivo).",
            "enum": [
              "pending",
              "running",
              "paused",
              "met",
              "breached"
            ]
          },
          "started_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "stopped_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "due_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Instante en que se agota (o se agotó) el plazo. Proyección mientras el reloj vive; congelado al parar. `null` si la organización no tiene jornada con la que calcularlo."
          },
          "consumed_minutes": {
            "type": "integer",
            "description": "Minutos LABORABLES consumidos, sin contar los tramos en pausa."
          },
          "remaining_minutes": {
            "type": [
              "integer",
              "null"
            ],
            "description": "`target_minutes - consumed_minutes` (negativo si se incumplió); `null` si el reloj no ha arrancado."
          },
          "elapsed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Instante hasta el que están contados los minutos consumidos."
          },
          "breached": {
            "type": "boolean"
          },
          "breached_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Instante exacto en que se agotó el plazo; `null` si no se incumplió."
          },
          "closed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "No nulo ⇒ el reloj está cerrado y su resultado ya no puede cambiar."
          },
          "based_on_reconstructed": {
            "type": "boolean",
            "description": "El cálculo se apoyó en algún tramo de historial RECONSTRUIDO (no observado en su momento). Se expone para poder decir «esto no es defendible al minuto» en vez de presentarlo como una medición."
          },
          "pauses": {
            "type": "array",
            "description": "Tramos en los que el reloj estuvo parado — la explicación de por qué tardó más.",
            "items": {
              "$ref": "#/components/schemas/SlaPauseSpan"
            }
          },
          "calendar_snapshot": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true,
            "description": "Calendario con el que se calculó: zona horaria, origen de la jornada, tramos por día de la semana, festivos aplicados y condiciones de la política. Es la mitad de la respuesta a «¿por qué venció a esa hora?»."
          }
        }
      },
      "ClientTimelineEntry": {
        "type": "object",
        "title": "ClientTimelineEntry",
        "description": "Un hecho de la vida del cliente, venga del módulo que venga (finanzas, CRM, proyectos, contratos, soporte o reuniones). Todos los campos opcionales están SIEMPRE presentes (`null` cuando no aplican a ese tipo).",
        "required": [
          "id",
          "type",
          "source_id",
          "occurred_at",
          "title",
          "subtitle",
          "status",
          "amount",
          "currency",
          "href"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Clave estable de la entrada, `\"{type}:{source_id}\"`. Dos fuentes distintas nunca colisionan aunque compartieran id.",
            "examples": [
              "invoice:9c8b7a65-4321-4fed-cba9-876543210fed"
            ]
          },
          "type": {
            "type": "string",
            "enum": [
              "invoice",
              "quote",
              "expense",
              "deal",
              "crm_note",
              "project",
              "contract",
              "contract_milestone",
              "request",
              "meeting"
            ],
            "description": "Qué clase de hecho es. `expense` son los gastos imputados a un proyecto DEL cliente (`expenses` no tiene `client_id`); `contract_milestone` son los hitos de pago de sus contratos —los únicos hitos que existen en el modelo de datos, los de proyecto no existen—; `request` son las peticiones de la mesa de soporte abiertas para él; `crm_note` son las anotaciones del CRM (llamada, email, reunión, nota, tarea) escritas contra una oportunidad SUYA —las escritas contra un lead no entran, porque un lead todavía no es este cliente—; `meeting` son las videollamadas agendadas con él.\n\n**Este enum es CERRADO y ensancharlo es un cambio de contrato.** Un cliente TypeScript con un `switch` exhaustivo o un `Record<type, …>` deja de compilar cuando aparece un valor nuevo, así que backend y front tienen que desplegarse juntos.",
            "examples": [
              "invoice"
            ]
          },
          "source_id": {
            "type": "string",
            "format": "uuid",
            "description": "Id de la fila de origen, en la tabla de su módulo."
          },
          "occurred_at": {
            "type": "string",
            "format": "date-time",
            "description": "Instante del HECHO, no de la escritura de la fila. Cada tipo usa la fecha que su módulo considera la suya: `issue_date` (factura, presupuesto), `date` (gasto), `due_date` (hito de contrato) y `created_at` (oportunidad, anotación del CRM, proyecto, contrato, petición). Una reunión usa su `scheduled_start` y, si es una llamada instantánea que nunca se agendó, su `created_at`. Cuando la fuente solo guarda una FECHA se emite a las 00:00 UTC de ese día.",
            "examples": [
              "2026-07-05T00:00:00Z"
            ]
          },
          "title": {
            "type": "string",
            "description": "Titular de la entrada (número de factura, título del deal…).",
            "examples": [
              "Factura INV-0042"
            ]
          },
          "subtitle": {
            "type": [
              "string",
              "null"
            ],
            "description": "Segunda línea con el contexto que da sentido a la entrada (el proyecto al que se imputó el gasto, la etapa del deal, el contrato del hito, el canal por el que entró la petición, la oportunidad sobre la que se anotó); `null` cuando el tipo no tiene ninguno.",
            "examples": [
              "Etapa: Propuesta enviada"
            ]
          },
          "status": {
            "type": [
              "string",
              "null"
            ],
            "description": "Estado tal y como lo guarda su módulo (`sent`, `paid`, `won`, `scheduled`, o un estado personalizado de una columna de tablero). SIN enum a propósito: el dominio es la unión de nueve vocabularios y uno de ellos es abierto por diseño. `crm_note` no tiene estado (`null`): una anotación no está en ningún sitio, ya ocurrió.",
            "examples": [
              "paid"
            ]
          },
          "amount": {
            "type": [
              "number",
              "null"
            ],
            "description": "Importe del hecho; `null` en los tipos que no mueven dinero.",
            "examples": [
              907.5
            ]
          },
          "currency": {
            "type": [
              "string",
              "null"
            ],
            "description": "Código ISO 4217 del importe; `null` si no hay importe.",
            "examples": [
              "EUR"
            ]
          },
          "href": {
            "type": "string",
            "description": "Ruta interna de la aplicación a la que lleva la entrada. Las oportunidades y las anotaciones del CRM apuntan al tablero (`/crm`) y no a una ficha por deal, porque esa ruta no existe; los hitos apuntan a su contrato; las reuniones, a su enlace único `/meet/{slug}`.",
            "examples": [
              "/finance/invoices/9c8b7a65-4321-4fed-cba9-876543210fed"
            ]
          }
        }
      },
      "ClientContact": {
        "type": "object",
        "title": "ClientContact",
        "description": "Persona de contacto de un cliente de la organización.",
        "required": [
          "id",
          "organization_id",
          "client_id",
          "name",
          "email",
          "phone",
          "job_title",
          "notes",
          "is_primary",
          "is_active",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "client_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "maxLength": 255,
            "examples": [
              "Ana Pérez"
            ]
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255,
            "description": "Correo de la persona, normalizado a minúsculas. ÚNICO en la organización: es lo que identifica a quien escribe, y repetido haría imposible saber de qué cliente es un mensaje entrante. `null` = solo se le localiza por teléfono."
          },
          "phone": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 50
          },
          "job_title": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 150,
            "description": "Cargo tal cual lo dicta el cliente (texto libre)."
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          },
          "is_primary": {
            "type": "boolean",
            "description": "Contacto principal del cliente: a quien se avisa cuando no hay una persona concreta detrás de un asunto. Como mucho uno por cliente — marcar otro apaga al anterior."
          },
          "is_active": {
            "type": "boolean",
            "description": "`false` = dado de baja. Deja de poder abrir peticiones, pero las que abrió siguen apuntando a él (por eso no se borra la ficha)."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ClientContactCreateIn": {
        "type": "object",
        "title": "ClientContactCreateIn",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 255
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255,
            "description": "Se normaliza a minúsculas. Único en la organización (409 `contact_email_taken` si ya está en otro contacto)."
          },
          "phone": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 50
          },
          "job_title": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 150
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          },
          "is_primary": {
            "type": "boolean",
            "default": false,
            "description": "Marcarlo apaga al contacto principal anterior del mismo cliente."
          }
        }
      },
      "ClientContactUpdateIn": {
        "type": "object",
        "title": "ClientContactUpdateIn",
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 255
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255
          },
          "phone": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 50
          },
          "job_title": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 150
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          },
          "is_primary": {
            "type": "boolean"
          },
          "is_active": {
            "type": "boolean"
          }
        }
      },
      "RequestType": {
        "type": "object",
        "title": "RequestType",
        "required": [
          "id",
          "organization_id",
          "name",
          "description",
          "project_id",
          "task_type",
          "default_priority",
          "active",
          "position",
          "fields",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "maxLength": 120,
            "examples": [
              "Avería"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "description": "Lo que lee el solicitante antes de elegir este tipo."
          },
          "project_id": {
            "type": "string",
            "format": "uuid",
            "description": "Cola destino. Las peticiones de este tipo nacen como tareas de este proyecto."
          },
          "task_type": {
            "type": "string",
            "description": "Con qué tipo nace la tarea. Reutiliza el vocabulario que el producto ya sabe expresar (`bug` = incidencia, `task` = solicitud) en vez de una taxonomía ITSM nueva.",
            "enum": [
              "task",
              "bug",
              "chore"
            ]
          },
          "default_priority": {
            "type": "string",
            "enum": [
              "low",
              "medium",
              "high",
              "urgent"
            ]
          },
          "active": {
            "type": "boolean",
            "description": "`false` = no se ofrece a quien pide y no admite peticiones nuevas (`request_type_inactive`); las que ya entraron por él siguen intactas."
          },
          "position": {
            "type": "integer"
          },
          "fields": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/RequestTypeField"
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "RequestTypeCreateIn": {
        "type": "object",
        "title": "RequestTypeCreateIn",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 120,
            "description": "Único en la organización (409 `request_type_name_taken`)."
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Cola destino; debe ser un proyecto de ESTA organización (422 `invalid_project`). Omitido = la cola de soporte de la organización, que se crea sola la primera vez como proyecto de SISTEMA: no consume cupo del plan y no aparece en el listado de proyectos."
          },
          "task_type": {
            "type": "string",
            "enum": [
              "task",
              "bug",
              "chore"
            ],
            "description": "Por defecto: task."
          },
          "default_priority": {
            "type": "string",
            "enum": [
              "low",
              "medium",
              "high",
              "urgent"
            ],
            "description": "Por defecto: medium."
          },
          "active": {
            "type": "boolean",
            "description": "Por defecto: true."
          },
          "position": {
            "type": "integer",
            "description": "Por defecto: 0."
          },
          "fields": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/RequestTypeFieldIn"
            }
          }
        }
      },
      "RequestTypeUpdateIn": {
        "type": "object",
        "title": "RequestTypeUpdateIn",
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 120
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "project_id": {
            "type": "string",
            "format": "uuid"
          },
          "task_type": {
            "type": "string",
            "enum": [
              "task",
              "bug",
              "chore"
            ]
          },
          "default_priority": {
            "type": "string",
            "enum": [
              "low",
              "medium",
              "high",
              "urgent"
            ]
          },
          "active": {
            "type": "boolean"
          },
          "position": {
            "type": "integer"
          },
          "fields": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/RequestTypeFieldIn"
            }
          }
        }
      },
      "RequestTypeField": {
        "type": "object",
        "title": "RequestTypeField",
        "required": [
          "field_def_id",
          "name",
          "field_type",
          "options",
          "required",
          "position"
        ],
        "properties": {
          "field_def_id": {
            "type": "string",
            "format": "uuid",
            "description": "Definición de campo personalizado (`custom_field_defs`) de ESTA organización."
          },
          "name": {
            "type": "string",
            "maxLength": 100
          },
          "field_type": {
            "type": "string",
            "enum": [
              "text",
              "number",
              "date",
              "select",
              "url",
              "email"
            ]
          },
          "options": {
            "type": [
              "array",
              "null"
            ],
            "description": "Opciones cuando `field_type` es `select`; `null` en el resto.",
            "items": {
              "type": "string"
            }
          },
          "required": {
            "type": "boolean"
          },
          "position": {
            "type": "integer"
          }
        }
      },
      "RequestTypeFieldIn": {
        "type": "object",
        "title": "RequestTypeFieldIn",
        "required": [
          "field_def_id"
        ],
        "properties": {
          "field_def_id": {
            "type": "string",
            "format": "uuid",
            "description": "Definición de campo personalizado de ESTA organización (422 `invalid_custom_field` si no lo es). No puede repetirse dentro del mismo formulario (422 `duplicate_field`)."
          },
          "required": {
            "type": "boolean",
            "default": false
          },
          "position": {
            "type": "integer",
            "default": 0
          }
        }
      },
      "SupportRequest": {
        "type": "object",
        "title": "SupportRequest",
        "required": [
          "id",
          "organization_id",
          "project_id",
          "number",
          "title",
          "description",
          "status",
          "priority",
          "type",
          "channel",
          "request_type_id",
          "requester_contact_id",
          "requester_client_id",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Id de la tarea que representa la petición."
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "project_id": {
            "type": "string",
            "format": "uuid",
            "description": "Cola en la que aterrizó."
          },
          "number": {
            "type": "integer",
            "description": "Número de la tarea dentro de su proyecto."
          },
          "title": {
            "type": "string",
            "maxLength": 255
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "status": {
            "type": "string",
            "description": "Estado de la tarea (los 4 base o una columna personalizada de la organización)."
          },
          "priority": {
            "type": "string",
            "enum": [
              "low",
              "medium",
              "high",
              "urgent"
            ]
          },
          "type": {
            "type": "string",
            "description": "Tipo de la tarea, heredado del tipo de petición."
          },
          "channel": {
            "type": "string",
            "description": "Por dónde ENTRÓ (`portal`, `email`, `phone`, `internal`, `api`). Es un hecho del momento de entrada que no se puede reconstruir después: decide a quién se le responde y qué peticiones tecleó alguien a mano.\n\nSIN `enum` a propósito (PJKT-2488). La columna es un `VARCHAR` que la base no restringe, así que un valor que la escritura no habría aceptado —SQL a mano, un volcado, un valor retirado, o el contenedor NUEVO escribiendo un canal que el VIEJO todavía no conoce durante un despliegue— PUEDE estar en la fila. Con `enum`, ese valor no rompía su fila: rompía el listado ENTERO de la organización con un 500. La ESCRITURA sí valida (`SupportRequestCreateIn.channel` es un enum cerrado y contesta 422), que es donde la validación protege de verdad. Mismo criterio que `AutomationRule.trigger_event`. Trata cualquier valor desconocido como «otro» en vez de indexar un mapa con la clave estrecha."
          },
          "request_type_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Formulario del que nació; `null` si ese tipo se borró después."
          },
          "requester_contact_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Persona que la pidió; `null` si no se identificó o su ficha se borró."
          },
          "requester_client_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Cliente para el que es. Se guarda APARTE del contacto a propósito: decide qué contrato y qué SLA aplican, y no puede evaporarse porque quien escribió cambie de empresa."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "SupportRequestCreateIn": {
        "type": "object",
        "title": "SupportRequestCreateIn",
        "required": [
          "request_type_id",
          "title"
        ],
        "properties": {
          "request_type_id": {
            "type": "string",
            "format": "uuid",
            "description": "Tipo ACTIVO de esta organización (422 `request_type_inactive`)."
          },
          "title": {
            "type": "string",
            "maxLength": 255
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "channel": {
            "type": "string",
            "enum": [
              "portal",
              "email",
              "phone",
              "internal"
            ],
            "default": "internal",
            "description": "Por dónde entró. `api` NO se admite aquí a propósito: decir «esto entró por la API» es un hecho de cómo entró, y dejar que se declare a mano lo convertiría en una opinión. Ese valor lo escribe solo el endpoint de ingesta (`POST /api/v1/support/ingest/requests`)."
          },
          "contact_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Quién lo pide. Si viene, MANDA: el cliente se deduce de él y se ignora `client_id`. Debe ser un contacto activo de esta organización (422 `invalid_contact` / `contact_inactive`)."
          },
          "client_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Para quién es, cuando todavía no hay una persona identificada. Se ignora si viene `contact_id`."
          },
          "priority": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "low",
              "medium",
              "high",
              "urgent",
              null
            ],
            "description": "Omitida = la prioridad por defecto del tipo de petición."
          },
          "fields": {
            "type": "array",
            "default": [],
            "description": "Respuestas a los campos del formulario. Los obligatorios tienen que venir (422 `missing_required_field`) y no se admiten campos que el tipo no pregunta (422 `unexpected_field`).",
            "items": {
              "$ref": "#/components/schemas/SupportRequestFieldValueIn"
            }
          }
        }
      },
      "SupportRequestFieldValueIn": {
        "type": "object",
        "title": "SupportRequestFieldValueIn",
        "required": [
          "field_def_id",
          "value"
        ],
        "properties": {
          "field_def_id": {
            "type": "string",
            "format": "uuid"
          },
          "value": {
            "type": "string"
          }
        }
      },
      "SupportSavedReply": {
        "type": "object",
        "title": "SupportSavedReply",
        "required": [
          "id",
          "organization_id",
          "title",
          "body",
          "shortcut",
          "created_by",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "title": {
            "type": "string",
            "maxLength": 120,
            "description": "Cómo se llama en el menú («Acuse de recibo», «Pedir logs»)."
          },
          "body": {
            "type": "string",
            "maxLength": 5000,
            "description": "El texto, con sus marcadores sin sustituir. Los tres que el cliente reemplaza al insertar son `{{cliente}}`, `{{peticion}}` y `{{agente}}`; cualquier otra cosa entre llaves se deja intacta."
          },
          "shortcut": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 32,
            "description": "Atajo para encontrarla al teclear (`/gracias`). Único dentro de la organización cuando existe; `null` = esta respuesta no tiene atajo, y varias pueden no tenerlo."
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Quién la creó. `null` cuando esa persona ya no existe en el sistema: se pierde la autoría, nunca la plantilla."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "SupportSavedReplyCreateIn": {
        "type": "object",
        "title": "SupportSavedReplyCreateIn",
        "required": [
          "title",
          "body"
        ],
        "properties": {
          "title": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120,
            "description": "Cómo se llama en el menú."
          },
          "body": {
            "type": "string",
            "minLength": 1,
            "maxLength": 5000,
            "description": "El texto, con los marcadores que se quieran (`{{cliente}}`, `{{peticion}}`, `{{agente}}`). Se guarda literal."
          },
          "shortcut": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 32,
            "pattern": "^/[a-z0-9][a-z0-9_-]{0,30}$",
            "description": "Atajo opcional: barra, y después minúsculas, dígitos, `-` o `_` (`/gracias`). El servidor NO lo normaliza —ni añade la barra ni baja a minúsculas—: lo que no encaje en el patrón es un 422, para que el atajo guardado sea siempre el que se escribió. Cadena vacía y `null` significan lo mismo: sin atajo. Repetirlo dentro de la organización responde 409 `saved_reply_shortcut_taken`."
          }
        }
      },
      "SupportSavedReplyUpdateIn": {
        "type": "object",
        "title": "SupportSavedReplyUpdateIn",
        "properties": {
          "title": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120
          },
          "body": {
            "type": "string",
            "minLength": 1,
            "maxLength": 5000
          },
          "shortcut": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 32,
            "pattern": "^/[a-z0-9][a-z0-9_-]{0,30}$",
            "description": "`null` (o cadena vacía) le quita el atajo; omitirlo lo deja como está."
          }
        }
      },
      "SupportEmailChannel": {
        "type": "object",
        "title": "SupportEmailChannel",
        "required": [
          "id",
          "organization_id",
          "address",
          "request_type_id",
          "active",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "address": {
            "type": "string",
            "maxLength": 255,
            "description": "Dirección del buzón, normalizada a minúsculas y sin etiqueta `+`. La etiqueta se reserva para el identificador de hilo que emite el servidor."
          },
          "request_type_id": {
            "type": "string",
            "format": "uuid",
            "description": "Tipo de petición con el que nace un correo que no pertenece a ningún hilo. Fija la cola y la prioridad: no las elige quien escribe."
          },
          "active": {
            "type": "boolean",
            "description": "Desactivado, el buzón deja de admitir correo. No se borra la dirección para que las peticiones que entraron por ella sigan sabiendo de dónde vienen."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "SupportEmailChannelCreateIn": {
        "type": "object",
        "title": "SupportEmailChannelCreate",
        "required": [
          "address",
          "request_type_id"
        ],
        "properties": {
          "address": {
            "type": "string",
            "minLength": 3,
            "maxLength": 255,
            "description": "Dirección del buzón. Se normaliza a minúsculas; se RECHAZA con `invalid_channel_address` si trae etiqueta `+`, porque esa etiqueta es la que transporta el identificador de hilo firmado por el servidor."
          },
          "request_type_id": {
            "type": "string",
            "format": "uuid",
            "description": "Tipo con el que nacen los correos que no pertenecen a ningún hilo."
          },
          "active": {
            "type": "boolean",
            "default": true
          }
        }
      },
      "SupportEmailChannelUpdateIn": {
        "type": "object",
        "title": "SupportEmailChannelUpdate",
        "additionalProperties": false,
        "properties": {
          "address": {
            "type": "string",
            "minLength": 3,
            "maxLength": 255
          },
          "request_type_id": {
            "type": "string",
            "format": "uuid"
          },
          "active": {
            "type": "boolean"
          }
        }
      },
      "InboundEmailAck": {
        "type": "object",
        "title": "InboundEmailAck",
        "required": [
          "accepted"
        ],
        "properties": {
          "accepted": {
            "type": "boolean",
            "description": "Siempre `true` cuando la FIRMA del proveedor valida. No dice nada de lo que pase después con el mensaje: el resultado de la ingesta se escribe en el registro interno, nunca en esta respuesta."
          }
        }
      },
      "SupportIngestKey": {
        "type": "object",
        "title": "SupportIngestKey",
        "required": [
          "id",
          "organization_id",
          "request_type_id",
          "name",
          "token_prefix",
          "hourly_limit",
          "created_by",
          "last_used_at",
          "revoked_at",
          "expires_at",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "request_type_id": {
            "type": "string",
            "format": "uuid",
            "description": "El formulario al que escribe, y con él la COLA y la prioridad de todo lo que entre. La web externa no elige ninguna de las dos."
          },
          "name": {
            "type": "string",
            "maxLength": 120,
            "description": "Para reconocerla en pantalla («Web de Tipsterland»)."
          },
          "token_prefix": {
            "type": "string",
            "description": "Primeros caracteres del token (`pjk_ingest_ab12`), solo para identificarla."
          },
          "hourly_limit": {
            "type": "integer",
            "description": "Peticiones que admite por hora. Se cuenta contra la BASE DE DATOS y no contra Redis: el rate limiter del producto es fail-open, así que el día que Redis cae este contador es lo único que separa una web en bucle de una bandeja inservible."
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "last_used_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "revoked_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Revocada = deja de valer en la petición siguiente."
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "`null` = no caduca nunca."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "SupportIngestKeyCreated": {
        "type": "object",
        "title": "SupportIngestKeyCreated",
        "required": [
          "id",
          "organization_id",
          "request_type_id",
          "name",
          "token_prefix",
          "hourly_limit",
          "created_by",
          "last_used_at",
          "revoked_at",
          "expires_at",
          "created_at",
          "token"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "request_type_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "maxLength": 120
          },
          "token_prefix": {
            "type": "string"
          },
          "hourly_limit": {
            "type": "integer"
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "last_used_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "revoked_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "token": {
            "type": "string",
            "description": "El secreto en claro, `pjk_ingest_…`. Guárdalo en este momento. Va en el SERVIDOR de la web que integra, nunca en su navegador (ver la descripción del endpoint de ingesta)."
          }
        }
      },
      "SupportIngestKeyCreateIn": {
        "type": "object",
        "title": "SupportIngestKeyCreateIn",
        "required": [
          "name",
          "request_type_id"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 120,
            "description": "Cómo se reconoce en pantalla («Web de Tipsterland»)."
          },
          "request_type_id": {
            "type": "string",
            "format": "uuid",
            "description": "Tipo de petición ACTIVO de esta organización (404 si no es de aquí, 422 `request_type_inactive` si está retirado). OBLIGATORIO: de él salen la cola y la prioridad, así que una credencial sin tipo tendría que elegir bandeja en tiempo de ingesta — y elegirla la web externa es justo lo que este diseño no permite."
          },
          "hourly_limit": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "maximum": 10000,
            "description": "Techo horario de ESTA credencial. Omitido o `null` = 60."
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Caducidad opcional. `null` = no caduca."
          }
        }
      },
      "SupportIngestRequestIn": {
        "type": "object",
        "title": "SupportIngestRequestIn",
        "required": [
          "title"
        ],
        "properties": {
          "title": {
            "type": "string",
            "maxLength": 255
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 20000
          },
          "external_ref": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 191,
            "description": "El id de la incidencia en el sistema de ORIGEN, para poder cruzar los dos cuando alguien pregunte. Informativo: NO deduplica."
          },
          "idempotency_key": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 128,
            "description": "La clave que SÍ deduplica, por credencial. Reintentar el mismo POST con la misma clave devuelve la petición original con **200** y `created: false`, en vez de abrir una segunda con 201. La unicidad está en la base de datos, no en una consulta previa: entre un SELECT y un INSERT no hay nada que separe dos entregas simultáneas del mismo evento."
          },
          "fields": {
            "type": "array",
            "default": [],
            "maxItems": 50,
            "description": "Respuestas a los campos del formulario del tipo (los ids se leen en `GET /api/v1/support/ingest/form`). Los obligatorios tienen que venir (422 `missing_required_field`) y no se admiten campos que el tipo no pregunta (422 `unexpected_field`).",
            "items": {
              "$ref": "#/components/schemas/SupportIngestFieldValueIn"
            }
          },
          "attachments": {
            "type": "array",
            "default": [],
            "maxItems": 5,
            "description": "Ficheros en base64 (una captura es la mitad de un parte de incidencia). Límites COMUNES del producto: 10 MiB por fichero, allow-list de extensiones y verificación de los BYTES contra la extensión. El CONJUNTO no puede pasar de 25.165.824 caracteres de base64 (18 MiB de fichero): es lo que cabe en el cuerpo que el proxy admite, así que pasarse contesta 422 en vez de morir en un corte de conexión. Un fichero rechazado NO tumba la incidencia: entra igual y el acuse dice cuál falló y por qué.",
            "items": {
              "$ref": "#/components/schemas/SupportIngestAttachmentIn"
            }
          }
        }
      },
      "SupportIngestFieldValueIn": {
        "type": "object",
        "title": "SupportIngestFieldValueIn",
        "required": [
          "field_def_id",
          "value"
        ],
        "properties": {
          "field_def_id": {
            "type": "string",
            "format": "uuid",
            "description": "Id que devuelve `GET /api/v1/support/ingest/form`."
          },
          "value": {
            "type": "string",
            "maxLength": 2000
          }
        }
      },
      "SupportIngestAttachmentIn": {
        "type": "object",
        "title": "SupportIngestAttachmentIn",
        "required": [
          "filename",
          "content_base64"
        ],
        "properties": {
          "filename": {
            "type": "string",
            "maxLength": 255
          },
          "content_base64": {
            "type": "string",
            "maxLength": 13982037,
            "description": "Contenido en base64 estricto. El techo del TEXTO son los 10 MiB de bytes reales ×4/3 con margen: es lo único que corta el cuerpo antes de decodificarlo, y así quien lo manda recibe un 422 que explica qué pasó en vez de un corte de conexión."
          }
        }
      },
      "SupportIngestAccepted": {
        "type": "object",
        "title": "SupportIngestAccepted",
        "required": [
          "id",
          "number",
          "created",
          "attachments_stored",
          "attachments_rejected"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Id de la petición (que es el de la tarea que la representa)."
          },
          "number": {
            "type": "integer",
            "description": "Número de la petición dentro de su cola."
          },
          "created": {
            "type": "boolean",
            "description": "`false` cuando la `idempotency_key` ya se había usado: la petición que se devuelve es la que se creó la primera vez, y la respuesta va con 200."
          },
          "attachments_stored": {
            "type": "integer"
          },
          "attachments_rejected": {
            "type": "array",
            "description": "Los ficheros que NO se guardaron, con el motivo. Se contesta en vez de callarse porque al otro lado hay un programa que puede corregirlo (la ingesta de correo se calla porque allí hay una persona a la que no se le puede devolver un error de validación).",
            "items": {
              "$ref": "#/components/schemas/SupportIngestRejectedAttachment"
            }
          }
        }
      },
      "SupportIngestRejectedAttachment": {
        "type": "object",
        "title": "SupportIngestRejectedAttachment",
        "required": [
          "filename",
          "reason"
        ],
        "properties": {
          "filename": {
            "type": "string"
          },
          "reason": {
            "type": "string",
            "description": "Texto legible para quien integra («File type '.exe' is not allowed…», «File content does not match its extension.»). No es un código estable: los códigos de error del producto viajan en el envelope, y aquí la incidencia SÍ entró."
          }
        }
      },
      "SupportIngestForm": {
        "type": "object",
        "title": "SupportIngestForm",
        "required": [
          "request_type_id",
          "name",
          "description",
          "fields"
        ],
        "properties": {
          "request_type_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "fields": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SupportIngestFormField"
            }
          }
        }
      },
      "SupportIngestFormField": {
        "type": "object",
        "title": "SupportIngestFormField",
        "required": [
          "field_def_id",
          "name",
          "field_type",
          "options",
          "required",
          "position"
        ],
        "properties": {
          "field_def_id": {
            "type": "string",
            "format": "uuid",
            "description": "El id que se manda en `fields[].field_def_id` al crear la incidencia."
          },
          "name": {
            "type": "string"
          },
          "field_type": {
            "type": "string",
            "description": "Tipo del campo. Hoy: `text`, `number`, `date`, `select`, `url` o `email`. Se sirve como cadena abierta a propósito (ver comentario): quien lo pinte debe tener una rama por defecto en vez de un `switch` exhaustivo."
          },
          "options": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            },
            "description": "Valores admitidos cuando `field_type` es `select`."
          },
          "required": {
            "type": "boolean"
          },
          "position": {
            "type": "integer"
          }
        }
      },
      "PortalIdentifyIn": {
        "type": "object",
        "title": "PortalIdentify",
        "description": "Dirección de correo con la que el visitante dice identificarse.",
        "required": [
          "email"
        ],
        "properties": {
          "email": {
            "type": "string",
            "description": "Correo del contacto. Se normaliza (minúsculas, sin espacios). El código NO se manda a esta dirección tal cual: se busca el contacto ACTIVO de ese cliente con ese correo y se manda a la dirección REGISTRADA en su ficha. Si el destino lo eligiera quien visita, verificar no probaría nada.",
            "examples": [
              "ana@acme.com"
            ]
          }
        }
      },
      "PortalCodeSent": {
        "type": "object",
        "title": "PortalCodeSent",
        "description": "Acuse de que la petición se ha procesado. Es idéntica exista o no ese contacto: quien abre el enlace ya sabe de qué empresa es el portal, así que responder distinto para «esa persona no está» convertiría el enlace en un comprobador de quién trabaja allí. El código solo sale hacia el buzón si hay a quién mandarlo.",
        "required": [
          "expires_in_seconds"
        ],
        "properties": {
          "expires_in_seconds": {
            "type": "integer",
            "description": "Vigencia del código de un solo uso, en segundos.",
            "examples": [
              600
            ]
          }
        }
      },
      "PortalVerifyIn": {
        "type": "object",
        "title": "PortalVerify",
        "description": "Dirección y código de seis dígitos recibido por correo.",
        "required": [
          "email",
          "code"
        ],
        "properties": {
          "email": {
            "type": "string",
            "description": "La misma dirección con la que se pidió el código (va ligada a él).",
            "examples": [
              "ana@acme.com"
            ]
          },
          "code": {
            "type": "string",
            "description": "Código de seis dígitos. Caduca, es de un solo uso y admite como mucho 5 intentos; agotarlos obliga a pedir uno nuevo (`portal_code_exhausted`).",
            "examples": [
              "481920"
            ]
          }
        }
      },
      "PortalIdentity": {
        "type": "object",
        "title": "PortalIdentity",
        "description": "Identidad verificada de la persona que ha entrado. Se obtiene al canjear el código y se mantiene en una cookie HttpOnly (`portal_session`) que el servidor revalida contra base de datos en cada petición: revocar el enlace, dar de baja al contacto o cerrar sesión surten efecto al instante.",
        "required": [
          "contact_name",
          "contact_email_hint",
          "client_name",
          "expires_at"
        ],
        "properties": {
          "contact_name": {
            "type": "string",
            "examples": [
              "Ana Ruiz"
            ]
          },
          "contact_email_hint": {
            "type": "string",
            "description": "Correo ENMASCARADO (`a***z@acme.com`). Nunca la dirección completa: la respuesta la puede leer cualquier script de la página y el portal asume que la pantalla puede estar en un equipo compartido.",
            "examples": [
              "a***a@acme.com"
            ]
          },
          "client_name": {
            "type": "string",
            "examples": [
              "Acme S.L."
            ]
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "Cuándo caduca la sesión.",
            "examples": [
              "2026-08-21T09:00:00Z"
            ]
          }
        }
      },
      "PortalRequestField": {
        "type": "object",
        "title": "PortalRequestField",
        "description": "Campo que el formulario pregunta, con lo justo para pintarlo y validarlo.",
        "required": [
          "field_def_id",
          "name",
          "field_type",
          "options",
          "required",
          "position"
        ],
        "properties": {
          "field_def_id": {
            "type": "string",
            "format": "uuid",
            "description": "Id con el que se responde a este campo al abrir la petición."
          },
          "name": {
            "type": "string",
            "examples": [
              "Sistema afectado"
            ]
          },
          "field_type": {
            "type": "string",
            "enum": [
              "text",
              "number",
              "date",
              "select",
              "url",
              "email"
            ]
          },
          "options": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            },
            "description": "Valores posibles cuando `field_type` es `select`; `null` en el resto."
          },
          "required": {
            "type": "boolean"
          },
          "position": {
            "type": "integer"
          }
        }
      },
      "PortalRequestType": {
        "type": "object",
        "title": "PortalRequestType",
        "description": "Formulario que el cliente puede elegir. NO incluye la cola (`project_id`): a qué bandeja interna aterriza una petición —y por tanto qué equipo la ve— es organización de la casa, no información del cliente.",
        "required": [
          "id",
          "name",
          "description",
          "fields"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "examples": [
              "Avería"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "fields": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PortalRequestField"
            }
          }
        }
      },
      "PortalRequestFieldValue": {
        "type": "object",
        "title": "PortalRequestFieldValue",
        "required": [
          "field_def_id",
          "value"
        ],
        "properties": {
          "field_def_id": {
            "type": "string",
            "format": "uuid"
          },
          "value": {
            "type": "string"
          }
        }
      },
      "PortalRequestCreateIn": {
        "type": "object",
        "title": "PortalRequestCreate",
        "description": "Lo que el cliente elige. NO lleva solicitante (sale de la sesión verificada, nunca del cuerpo), ni prioridad (la fija el tipo: si la eligiera quien escribe, todas serían urgentes), ni cola (elegir bandeja es elegir qué equipo lo ve).",
        "required": [
          "request_type_id",
          "title"
        ],
        "properties": {
          "request_type_id": {
            "type": "string",
            "format": "uuid",
            "description": "Tipo de petición ACTIVO de la organización (`request_type_inactive` si se retiró)."
          },
          "title": {
            "type": "string",
            "maxLength": 255
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "fields": {
            "type": "array",
            "default": [],
            "description": "Respuestas a los campos del formulario. Los obligatorios tienen que venir (`missing_required_field`) y no se aceptan campos que este tipo no pregunta (`unexpected_field`).",
            "items": {
              "$ref": "#/components/schemas/PortalRequestFieldValue"
            }
          }
        }
      },
      "PortalRequest": {
        "type": "object",
        "title": "PortalRequest",
        "description": "Lo que NO está aquí importa tanto como lo que está: no hay responsable interno, ni proyecto/cola, ni el estado crudo del tablero, ni nada del SLA. El estado va traducido al vocabulario del cliente porque los slugs del tablero son internos (`in_review` no significa nada fuera) y porque renombrar una columna no puede romper la pantalla del cliente.",
        "required": [
          "id",
          "title",
          "description",
          "status",
          "status_label",
          "request_type_name",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "title": {
            "type": "string"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "open",
              "in_progress",
              "on_hold",
              "resolved",
              "cancelled"
            ],
            "description": "`on_hold` agrupa «en revisión» y «bloqueada»: al cliente le importa que su petición no avanza, no cuál de las formas internas de no avanzar es."
          },
          "status_label": {
            "type": "string",
            "description": "Etiqueta legible del estado, en español.",
            "examples": [
              "En espera"
            ]
          },
          "request_type_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nombre del formulario del que nació; `null` si ese tipo se borró."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PortalRequestComment": {
        "type": "object",
        "title": "PortalRequestComment",
        "description": "SOLO llegan aquí los comentarios `public`. Los internos del equipo se filtran en la consulta a base de datos, no al construir la respuesta: lo que no se puede enseñar ni siquiera se lee.",
        "required": [
          "id",
          "author_kind",
          "author_name",
          "body",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "author_kind": {
            "type": "string",
            "enum": [
              "client",
              "team"
            ],
            "description": "Quién lo escribió, para poder distinguir lo propio de la respuesta."
          },
          "author_name": {
            "type": "string"
          },
          "body": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PortalRequestCommentCreateIn": {
        "type": "object",
        "title": "PortalRequestCommentCreate",
        "description": "Nace `public` por definición: lo escribe el cliente, así que esconderle lo que él mismo acaba de decir no tendría sentido.",
        "required": [
          "body"
        ],
        "properties": {
          "body": {
            "type": "string",
            "minLength": 1,
            "maxLength": 5000
          }
        }
      },
      "PortalRequestDetail": {
        "type": "object",
        "title": "PortalRequestDetail",
        "required": [
          "request",
          "comments"
        ],
        "properties": {
          "request": {
            "$ref": "#/components/schemas/PortalRequest"
          },
          "comments": {
            "type": "array",
            "description": "Conversación en orden CRONOLÓGICO (se lee de arriba abajo).",
            "items": {
              "$ref": "#/components/schemas/PortalRequestComment"
            }
          }
        }
      },
      "PortalRequestAttachment": {
        "type": "object",
        "title": "PortalRequestAttachment",
        "required": [
          "id",
          "filename",
          "mime_type",
          "file_size",
          "uploaded_by_client",
          "download_url",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "filename": {
            "type": "string",
            "description": "Nombre mostrable, ya normalizado al basename (un `../../etc/passwd.png` se guarda como `passwd.png`)."
          },
          "mime_type": {
            "type": "string",
            "description": "Deducido de la extensión ya validada, nunca del que declara el cliente."
          },
          "file_size": {
            "type": "integer"
          },
          "uploaded_by_client": {
            "type": "boolean",
            "description": "`true` si lo subió el cliente desde el portal; `false` si lo subió el equipo desde la ficha. Sirve para pintarlos distinto sin adivinar."
          },
          "download_url": {
            "type": "string",
            "description": "Endpoint del PORTAL (`…/requests/{request_id}/attachments/{id}/download`), no el de la organización: ese exige un usuario del equipo, así que enseñárselo al cliente sería una puerta que no puede abrir."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PortalRequestAttachmentIn": {
        "type": "object",
        "title": "PortalRequestAttachmentIn",
        "required": [
          "filename",
          "content_base64"
        ],
        "properties": {
          "filename": {
            "type": "string",
            "maxLength": 255
          },
          "content_base64": {
            "type": "string",
            "maxLength": 13982037,
            "description": "Contenido en base64 estricto. El techo del TEXTO son los 10 MiB de bytes reales ×4/3 con margen: es lo único que corta el cuerpo antes de decodificarlo. 422 `invalid_attachment` si el fichero no pasa — al otro lado hay una persona delante de una pantalla que puede reintentar, así que se le dice (a diferencia de la ingesta de correo, que se calla)."
          }
        }
      },
      "SearchHit": {
        "type": "object",
        "title": "SearchHit",
        "description": "Resultado de búsqueda global (un elemento encontrado).",
        "required": [
          "type",
          "id",
          "title",
          "url_hint"
        ],
        "properties": {
          "type": {
            "type": "string",
            "description": "Tipo de entidad encontrada. Los tipos financieros (invoice, expense, supplier) solo se devuelven si el rol del llamante es member o superior según la policy de finanzas.",
            "enum": [
              "task",
              "project",
              "client",
              "invoice",
              "document",
              "employee",
              "expense",
              "supplier",
              "member"
            ],
            "examples": [
              "project"
            ]
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Identificador de la entidad.",
            "examples": [
              "0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          },
          "title": {
            "type": "string",
            "description": "Texto principal del resultado (nombre, número, título…). Para expense: número formateado EXP-NNNN o descripción si sin número.",
            "examples": [
              "Proyecto Alpha"
            ]
          },
          "subtitle": {
            "type": [
              "string",
              "null"
            ],
            "description": "Texto secundario opcional. task: estado de la tarea (todo, in_progress, done…). invoice: nombre cliente + estado (\"Acme Corp · draft\"). expense: descripción o vendor. supplier: NIF o email. member: email del usuario. client: nombre de empresa.",
            "examples": [
              "3XA Inc"
            ]
          },
          "reference": {
            "type": [
              "string",
              "null"
            ],
            "description": "Referencia legible tipo JIRA \"KEY-N\" (Project.key + Task.number), solo presente para hits de tipo task (p. ej. \"PJKT-123\"). Null para el resto de entidades.",
            "examples": [
              "PJKT-123"
            ]
          },
          "url_hint": {
            "type": "string",
            "description": "Ruta relativa sugerida para navegar al resultado en el SPA.",
            "examples": [
              "/projects/0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          }
        }
      },
      "CalendarFeed": {
        "type": "object",
        "title": "CalendarFeed",
        "description": "Token e URL de suscripción del feed ICS personal del usuario.",
        "required": [
          "token",
          "subscribe_url"
        ],
        "properties": {
          "token": {
            "type": "string",
            "description": "Token opaco (urlsafe-base64, 43 chars) que autentica el feed ICS público.",
            "examples": [
              "3nQ4rZlXkvP8wJdMlAuEFb9m7cH2YGsOtNxRpBvKAe0"
            ]
          },
          "subscribe_url": {
            "type": "string",
            "format": "uri",
            "description": "URL pública completa del feed `.ics` lista para suscribir en cualquier cliente de calendario.",
            "examples": [
              "https://api.projekt.3xa.es/api/v1/calendar/3nQ4rZlXkvP8wJdMlAuEFb9m7cH2YGsOtNxRpBvKAe0.ics"
            ]
          }
        }
      },
      "CalendarEvent": {
        "type": "object",
        "title": "CalendarEvent",
        "description": "Evento del calendario de la organización dentro del rango consultado: un festivo (`kind=holiday`) o una ausencia aprobada (`kind=absence`).",
        "required": [
          "kind",
          "title",
          "start_date",
          "end_date",
          "reference_id"
        ],
        "properties": {
          "kind": {
            "type": "string",
            "enum": [
              "holiday",
              "absence"
            ],
            "description": "Tipo de evento; `holiday` (festivo) o `absence` (ausencia aprobada)."
          },
          "title": {
            "type": "string",
            "description": "Etiqueta legible del evento. En festivos, el nombre del festivo. En ausencias, la etiqueta del tipo de ausencia (p. ej. «Vacaciones») — o el generico «Ausencia» cuando quien consulta no puede ver el motivo (ver `leave_type`). El titulo lleva el mismo dato que `leave_type` en forma legible, asi que los dos se tapan juntos o no se tapa nada.",
            "examples": [
              "Navidad"
            ]
          },
          "start_date": {
            "type": "string",
            "format": "date",
            "description": "Primer día del evento (inclusive). En festivos coincide con `end_date`."
          },
          "end_date": {
            "type": "string",
            "format": "date",
            "description": "Último día del evento (inclusive)."
          },
          "reference_id": {
            "type": "string",
            "format": "uuid",
            "description": "ID del recurso subyacente (el festivo o la solicitud de ausencia)."
          },
          "recurring": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Solo festivos; `true` si el festivo es recurrente anual. `null` en ausencias."
          },
          "employee_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Solo ausencias; ficha del empleado ausente. `null` en festivos."
          },
          "user_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Solo ausencias; usuario vinculado al empleado, si lo hay. `null` en festivos."
          },
          "employee_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Solo ausencias; nombre del empleado ausente para pintar en el calendario.",
            "examples": [
              "Ana García"
            ]
          },
          "leave_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "Solo ausencias; tipo de ausencia (`vacation`, `sick`, `unpaid`, `other`). `null` en festivos — y tambien, desde el 2026-09-10, en las ausencias AJENAS de quien no es manager+: «vacaciones» y «baja» no son la misma informacion. Quien falta y que dias se sigue viendo (`employee_name`, las fechas); por que, no. Misma regla que `Absence.leave_type`.",
            "examples": [
              "vacation"
            ]
          }
        }
      },
      "BusinessMinutes": {
        "type": "object",
        "title": "BusinessMinutes",
        "description": "Minutos LABORABLES transcurridos entre dos instantes según el calendario de la organización: su jornada semanal, sus festivos (los recurrentes proyectados al año que corresponda) y su zona horaria. No cuenta noches, fines de semana ni festivos. Al calcularse en hora local de la organización, los cambios de hora de marzo y octubre no desplazan el resultado.",
        "required": [
          "start",
          "end",
          "minutes",
          "timezone",
          "schedule_source"
        ],
        "properties": {
          "start": {
            "type": "string",
            "format": "date-time",
            "description": "Instante inicial del rango, normalizado a UTC.",
            "examples": [
              "2026-03-27T16:00:00Z"
            ]
          },
          "end": {
            "type": "string",
            "format": "date-time",
            "description": "Instante final del rango, normalizado a UTC.",
            "examples": [
              "2026-03-30T10:00:00Z"
            ]
          },
          "minutes": {
            "type": "integer",
            "description": "Minutos laborables enteros en `[start, end)`. Se trunca hacia abajo. Un rango de duración cero devuelve `0`.",
            "examples": [
              240
            ]
          },
          "timezone": {
            "type": "string",
            "description": "Zona horaria IANA de la organización con la que se ha calculado.",
            "examples": [
              "Europe/Madrid"
            ]
          },
          "schedule_source": {
            "type": "string",
            "enum": [
              "organization",
              "default"
            ],
            "description": "De dónde sale la jornada usada: `organization` si la organización tiene horario configurado, `default` si se ha aplicado el respaldo (lunes a viernes de 09:00 a 18:00). Con `default` el número es válido, pero conviene avisar de que el horario real no está configurado.",
            "examples": [
              "organization"
            ]
          }
        }
      },
      "BusinessDue": {
        "type": "object",
        "title": "BusinessDue",
        "description": "Instante en el que se cumplen `minutes` minutos LABORABLES contados desde `start`, según el calendario de la organización (jornada, festivos y zona horaria). Es el cálculo de un compromiso de servicio («4 horas laborables de respuesta») y sirve igual para estimar una fecha de entrega.",
        "required": [
          "start",
          "minutes",
          "due_at",
          "timezone",
          "schedule_source"
        ],
        "properties": {
          "start": {
            "type": "string",
            "format": "date-time",
            "description": "Instante desde el que se ha contado, en UTC (por defecto, ahora).",
            "examples": [
              "2026-03-27T16:00:00Z"
            ]
          },
          "minutes": {
            "type": "integer",
            "description": "Minutos laborables solicitados.",
            "examples": [
              240
            ]
          },
          "due_at": {
            "type": "string",
            "format": "date-time",
            "description": "Instante de vencimiento, en UTC. Si el plazo se agota justo al cierre de la jornada, el vencimiento ES el cierre (no la apertura del día siguiente). Con `minutes = 0` coincide con `start`.",
            "examples": [
              "2026-03-30T10:00:00Z"
            ]
          },
          "timezone": {
            "type": "string",
            "description": "Zona horaria IANA de la organización con la que se ha calculado.",
            "examples": [
              "Europe/Madrid"
            ]
          },
          "schedule_source": {
            "type": "string",
            "enum": [
              "organization",
              "default"
            ],
            "description": "De dónde sale la jornada usada: `organization` si la organización tiene horario configurado, `default` si se ha aplicado el respaldo (lunes a viernes de 09:00 a 18:00).",
            "examples": [
              "organization"
            ]
          }
        }
      },
      "Holiday": {
        "type": "object",
        "title": "Holiday",
        "description": "Día festivo (no laborable) de la organización.",
        "required": [
          "id",
          "organization_id",
          "date",
          "name",
          "recurring",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "a1b2c3d4-e5f6-7a8b-9c0d-e1f2a3b4c5d6"
            ]
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          },
          "date": {
            "type": "string",
            "format": "date",
            "description": "Fecha del festivo (`YYYY-MM-DD`). En festivos recurrentes es la fecha ancla; el festivo se repite cada año en su mismo mes y día.",
            "examples": [
              "2026-12-25"
            ]
          },
          "name": {
            "type": "string",
            "maxLength": 120,
            "examples": [
              "Navidad"
            ]
          },
          "recurring": {
            "type": "boolean",
            "description": "Si es `true`, el festivo se repite cada año en el mismo mes y día.",
            "examples": [
              true
            ]
          },
          "scope": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nivel administrativo que declara el festivo: `nacional` (todo el país), `regional` (división de primer nivel: comunidad autónoma, state, province…) o `local` (municipio). `autonomico` es la grafía española del nivel regional y se conserva en los festivos importados así. `null` si no se especificó (p. ej. festivos creados antes de existir este campo). Deliberadamente NO es un `enum`: es una etiqueta descriptiva y ningún cálculo se ramifica por su valor, así que admitir un nivel nuevo no debe poder romper a un cliente ya generado.",
            "examples": [
              "nacional"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-25T10:00:00Z"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-25T10:15:00Z"
            ]
          }
        }
      },
      "HolidayCreateIn": {
        "type": "object",
        "title": "HolidayCreateIn",
        "description": "Cuerpo de creación de un festivo de la organización.",
        "required": [
          "date",
          "name"
        ],
        "properties": {
          "date": {
            "type": "string",
            "format": "date",
            "description": "Fecha del festivo (`YYYY-MM-DD`). En recurrentes, la fecha ancla.",
            "examples": [
              "2026-12-25"
            ]
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120,
            "examples": [
              "Navidad"
            ]
          },
          "recurring": {
            "type": "boolean",
            "default": false,
            "description": "Si es `true`, el festivo se repite cada año en el mismo mes y día.",
            "examples": [
              true
            ]
          }
        }
      },
      "HolidayUpdateIn": {
        "type": "object",
        "title": "HolidayUpdateIn",
        "description": "Actualización parcial de un festivo; solo los campos presentes se aplican.",
        "properties": {
          "date": {
            "type": "string",
            "format": "date",
            "description": "Nueva fecha del festivo (`YYYY-MM-DD`).",
            "examples": [
              "2026-12-26"
            ]
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120,
            "examples": [
              "Navidad"
            ]
          },
          "recurring": {
            "type": "boolean",
            "description": "Si es `true`, el festivo se repite cada año en el mismo mes y día.",
            "examples": [
              true
            ]
          }
        }
      },
      "HolidayImportRowIn": {
        "type": "object",
        "title": "HolidayImportRowIn",
        "description": "Una fila del CSV de festivos a importar (`fecha`, `nombre`, `ambito`). Los valores llegan como texto y se validan por fila en el servidor.",
        "properties": {
          "fecha": {
            "type": "string",
            "default": "",
            "description": "Fecha del festivo en formato `YYYY-MM-DD`.",
            "examples": [
              "2026-12-25"
            ]
          },
          "nombre": {
            "type": "string",
            "default": "",
            "description": "Nombre del festivo (no vacío, máx. 120 caracteres).",
            "examples": [
              "Navidad"
            ]
          },
          "ambito": {
            "type": "string",
            "default": "",
            "description": "Nivel administrativo que declara el festivo: `nacional`, `regional` o `local`. Se aceptan los sinónimos de cada país sin distinguir mayúsculas ni acentos — `federal`, `national` → `nacional`; `state`, `provincial`, `autonomico` → nivel regional (`autonomico` se guarda con esa grafía); `municipal`, `city` → `local`. `estatal` se RECHAZA por ambiguo (nacional en España, regional en EE. UU.) con un error de esa fila.",
            "examples": [
              "nacional"
            ]
          }
        }
      },
      "HolidayImportIn": {
        "type": "object",
        "title": "HolidayImportIn",
        "description": "Importación en bloque de festivos. `rows` son las filas del CSV ya parseadas (cabecera excluida). Idempotente por `(organización, fecha)`.",
        "required": [
          "rows"
        ],
        "properties": {
          "rows": {
            "type": "array",
            "minItems": 1,
            "maxItems": 1000,
            "description": "Filas del CSV a importar (entre 1 y 1000).",
            "items": {
              "$ref": "#/components/schemas/HolidayImportRowIn"
            }
          }
        }
      },
      "HolidayImportError": {
        "type": "object",
        "title": "HolidayImportError",
        "description": "Motivo por el que una fila del CSV de festivos no pudo importarse.",
        "required": [
          "fila",
          "motivo"
        ],
        "properties": {
          "fila": {
            "type": "integer",
            "description": "Número de fila de datos (1 = primera fila tras la cabecera).",
            "examples": [
              3
            ]
          },
          "motivo": {
            "type": "string",
            "description": "Descripción legible del motivo del rechazo.",
            "examples": [
              "Fecha inválida (formato esperado YYYY-MM-DD)."
            ]
          }
        }
      },
      "HolidayImportResult": {
        "type": "object",
        "title": "HolidayImportResult",
        "description": "Resumen de una importación de festivos: cuántos se crearon, cuántos se omitieron (fecha ya existente) y los errores por fila.",
        "required": [
          "created",
          "skipped",
          "errors"
        ],
        "properties": {
          "created": {
            "type": "integer",
            "description": "Festivos creados.",
            "examples": [
              12
            ]
          },
          "skipped": {
            "type": "integer",
            "description": "Filas omitidas por existir ya un festivo en esa fecha.",
            "examples": [
              2
            ]
          },
          "errors": {
            "type": "array",
            "description": "Errores por fila (fila inválida); el resto del lote se procesa igual.",
            "items": {
              "$ref": "#/components/schemas/HolidayImportError"
            }
          }
        }
      },
      "Error": {
        "type": "object",
        "title": "Error",
        "description": "Envelope de error estándar: `{\"error\":{\"code\",\"message\",\"request_id?\"}}`.",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string",
                "description": "Código estable machine-readable (p. ej. `invalid_credentials`, `email_taken`, `slug_taken`, `not_found`, `validation_error`, `unauthorized`, `not_ready`).",
                "examples": [
                  "not_found"
                ]
              },
              "message": {
                "type": "string",
                "description": "Mensaje legible para humanos.",
                "examples": [
                  "Organization not found"
                ]
              },
              "request_id": {
                "type": "string",
                "description": "Identificador de la petición para correlación con logs (opcional).",
                "examples": [
                  "req_01J9ZK3M8QW2"
                ]
              }
            }
          }
        }
      },
      "PayrollRun": {
        "type": "object",
        "title": "PayrollRun",
        "description": "Corrida de nóminas mensual (periodo YYYY-MM).",
        "required": [
          "id",
          "organization_id",
          "period",
          "status",
          "created_by",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "period": {
            "type": "string",
            "description": "Periodo en formato YYYY-MM (e.g. '2026-07').",
            "examples": [
              "2026-07"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "processed"
            ],
            "description": "Estado de la corrida.",
            "default": "draft"
          },
          "created_by": {
            "type": "string",
            "format": "uuid",
            "description": "UUID del usuario que creó la corrida."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PayrollRunWithPayslips": {
        "type": "object",
        "title": "PayrollRunWithPayslips",
        "description": "Corrida de nóminas con sus payslips individuales incluidos.",
        "required": [
          "id",
          "organization_id",
          "period",
          "status",
          "created_by",
          "created_at",
          "payslips"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "period": {
            "type": "string",
            "description": "Periodo en formato YYYY-MM.",
            "examples": [
              "2026-07"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "processed"
            ],
            "default": "draft"
          },
          "created_by": {
            "type": "string",
            "format": "uuid"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "payslips": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Payslip"
            }
          }
        }
      },
      "Payslip": {
        "type": "object",
        "title": "Payslip",
        "description": "Nómina mensual de un empleado con desglose bruto→neto.",
        "required": [
          "id",
          "organization_id",
          "payroll_run_id",
          "employee_id",
          "gross",
          "ss_worker",
          "irpf",
          "net",
          "irpf_rate_pct",
          "ss_rate_pct",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "payroll_run_id": {
            "type": "string",
            "format": "uuid"
          },
          "employee_id": {
            "type": "string",
            "format": "uuid"
          },
          "gross": {
            "type": "number",
            "description": "Salario bruto mensual (gross_annual / pagas).",
            "examples": [
              2500
            ]
          },
          "ss_worker": {
            "type": "number",
            "description": "Cuota obrera de la Seguridad Social (6.35% del bruto).",
            "examples": [
              158.75
            ]
          },
          "irpf": {
            "type": "number",
            "description": "Retención IRPF (irpf_rate% del bruto).",
            "examples": [
              375
            ]
          },
          "net": {
            "type": "number",
            "description": "Neto = gross - ss_worker - irpf.",
            "examples": [
              1966.25
            ]
          },
          "irpf_rate_pct": {
            "type": "number",
            "description": "Tipo IRPF aplicado (porcentaje; snapshot en el momento del procesado).",
            "examples": [
              15
            ]
          },
          "ss_rate_pct": {
            "type": "number",
            "description": "Tipo total SS obrero aplicado (decimal; e.g. 0.0635 = 6.35%).",
            "examples": [
              0.0635
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "WorkSchedule": {
        "type": "object",
        "title": "WorkSchedule",
        "description": "Turno de horario laboral semanal de un empleado (HH:MM start/end).",
        "required": [
          "id",
          "organization_id",
          "employee_id",
          "weekday",
          "start_time",
          "end_time",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "employee_id": {
            "type": "string",
            "format": "uuid"
          },
          "weekday": {
            "type": "integer",
            "minimum": 0,
            "maximum": 6,
            "description": "Día de la semana (0=Lunes … 6=Domingo).",
            "examples": [
              0
            ]
          },
          "start_time": {
            "type": "string",
            "description": "Hora de inicio en formato HH:MM (24 h).",
            "examples": [
              "09:00"
            ]
          },
          "end_time": {
            "type": "string",
            "description": "Hora de fin en formato HH:MM (24 h).",
            "examples": [
              "17:00"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "WorkScheduleEmployeeSummary": {
        "type": "object",
        "title": "WorkScheduleEmployeeSummary",
        "description": "Horario semanal de un empleado con sus turnos y total de horas.",
        "required": [
          "employee_id",
          "entries",
          "weekly_hours"
        ],
        "properties": {
          "employee_id": {
            "type": "string",
            "format": "uuid"
          },
          "entries": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/WorkSchedule"
            }
          },
          "weekly_hours": {
            "type": "number",
            "description": "Total de horas semanales calculadas a partir de los turnos.",
            "examples": [
              40
            ]
          }
        }
      },
      "SchedulesOverviewItem": {
        "type": "object",
        "title": "SchedulesOverviewItem",
        "description": "Resumen de horario de un empleado en la vista org-wide.",
        "required": [
          "employee_id",
          "entries",
          "weekly_hours"
        ],
        "properties": {
          "employee_id": {
            "type": "string",
            "format": "uuid"
          },
          "entries": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/WorkSchedule"
            }
          },
          "weekly_hours": {
            "type": "number",
            "description": "Total de horas semanales calculadas.",
            "examples": [
              40
            ]
          }
        }
      },
      "GanttTask": {
        "type": "object",
        "title": "GanttTask",
        "description": "One Gantt task bar. Tasks without dates are included with null start_date/due_date so the frontend can bucket them in an unscheduled lane.",
        "required": [
          "id",
          "title",
          "status",
          "assignee_id",
          "story_points",
          "start_date",
          "due_date",
          "dependencies"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "title": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "description": "Estado de la tarea, el mismo valor que `Task.status`. Además de los 4 base (todo/in_progress/done/cancelled) puede ser un estado PERSONALIZADO de columna de tablero (slug `^[a-z0-9_]+$`), así que NO es un conjunto cerrado."
          },
          "assignee_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "story_points": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Story-point estimate; null when not set."
          },
          "start_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "ISO 8601 start date; null when not scheduled."
          },
          "due_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "ISO 8601 due date; null when not scheduled."
          },
          "dependencies": {
            "type": "array",
            "description": "Dependency links originating from this task.",
            "items": {
              "$ref": "#/components/schemas/gantt_dependency_link"
            }
          }
        }
      },
      "GanttResponse": {
        "type": "object",
        "title": "GanttResponse",
        "description": "Complete Gantt payload: the project identifier plus all task bars with dependency links.",
        "required": [
          "project_id",
          "tasks"
        ],
        "properties": {
          "project_id": {
            "type": "string",
            "format": "uuid"
          },
          "tasks": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/GanttTask"
            }
          }
        }
      },
      "RoadmapItem": {
        "type": "object",
        "title": "RoadmapItem",
        "description": "Project timeline entry for the org-level roadmap. `start` and `end` are the earliest and latest date found among the project's TASKS (their `start_date` and `due_date`, so a task carrying only one of the two still counts). Both are null when no task has a date: the project is not planned, and the roadmap says so instead of drawing an invented bar.",
        "required": [
          "id",
          "name",
          "start",
          "end",
          "task_count",
          "done_count",
          "closed_count",
          "completion_pct",
          "by_status"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Logotipo del proyecto (avatar de la fila del roadmap)."
          },
          "start": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Timeline start (ISO 8601 date): the earliest date among the project's tasks. Null when no task has a date."
          },
          "end": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Timeline end: the latest date among the project's tasks. Null when no task has a date."
          },
          "task_count": {
            "type": "integer",
            "description": "Total number of tasks in the project."
          },
          "done_count": {
            "type": "integer",
            "description": "Number of tasks with status `done`."
          },
          "closed_count": {
            "type": "integer",
            "description": "Tareas en fase FINAL (status `done` o `cancelled`). Las abiertas = task_count − closed_count; las canceladas NO cuentan como abiertas."
          },
          "completion_pct": {
            "type": "number",
            "format": "decimal",
            "description": "done_count / task_count × 100, rounded to 2 dp (0.00–100.00)."
          },
          "by_status": {
            "type": "array",
            "description": "Desglose de las tareas del proyecto POR ESTADO, para pintar la barra segmentada de la tarjeta «Subproyectos» sin una petición más (el dueño, 09/09/2026): la tarjeta ya pide este roadmap, y con `done_count` / `closed_count` solo salían tres cubos donde el gordo mezclaba todo + in_progress + in_review + blocked. La suma de los `count` es `task_count`.\nDOS DIFERENCIAS con `SprintStats.by_status`, que tiene esta misma anatomía, y NO son un descuido:\n1. `status` es un STRING LIBRE, no un enum de cuatro. La columna\n   `tasks.status` es un `String(20)` sin tipar A PROPÓSITO (models.py:76\n   y :121): además de los cuatro de `TaskStatus` guarda los slugs de las\n   columnas PERSONALIZADAS de tablero (`^[a-z0-9_]+$`). Un enum aquí\n   sería una promesa que el primer tablero con columnas propias\n   desmiente. Quien consuma esto pliega el slug a su categoría (el front\n   lo hace con `taskStatusCategory()`).\n\n2. Solo aparecen los estados PRESENTES en el proyecto: es un GROUP BY\n   status, así que un estado con recuento 0 NO se emite. `SprintStats`\n   puede emitir sus cuatro siempre porque su conjunto es cerrado; aquí\n   la lista de estados posibles no lo es y no hay ceros que emitir.\n   Un estado que no aparece se lee como CERO tareas en ese estado.\n\nY UNA TERCERA COSA, porque en `/api/v1` un nombre ya no se cambia. Este contrato tiene YA otra forma para esta misma idea, y más cercana que `SprintStats`: `PlatformProjectDetail.task_counts` y `PlatformOrganizationDetail.task_counts` son un `object` con `additionalProperties: integer` —un mapa estado→entero— y también sobre el conjunto ABIERTO de slugs de tablero. Se eligió el ARRAY y no el mapa por dos motivos: el array conserva el ORDEN, que es justo lo que la barra segmentada necesita para pintar el flujo del tablero de izquierda a derecha (un objeto JSON no promete orden de claves), y `by_status` es el nombre que ya se lee en el producto mientras `task_counts` es superficie de plataforma/ops, que sirve a superadmin y no a una pantalla. Quedan las dos formas en v1 a sabiendas; si algún día se unifican, se unifican hacia ésta.",
            "items": {
              "type": "object",
              "required": [
                "status",
                "count"
              ],
              "properties": {
                "status": {
                  "type": "string",
                  "description": "Slug del estado tal y como está guardado: los cuatro base (`todo`, `in_progress`, `done`, `cancelled`) o el de una columna personalizada de tablero. Sin enum, por lo dicho arriba.",
                  "examples": [
                    "in_progress"
                  ]
                },
                "count": {
                  "type": "integer",
                  "description": "Tareas del proyecto en ese estado (siempre ≥ 1).",
                  "examples": [
                    3
                  ]
                }
              }
            }
          }
        }
      },
      "EvmSummary": {
        "type": "object",
        "title": "EvmSummary",
        "description": "Earned Value Management summary for a project, computed in effort-hours. All monetary-rate-free: BAC/EV/AC/PV measure hours, not cost.",
        "required": [
          "as_of",
          "bac",
          "ev",
          "ac",
          "pv",
          "cpi",
          "spi",
          "eac",
          "etc",
          "vac",
          "pv_note"
        ],
        "properties": {
          "as_of": {
            "type": "string",
            "format": "date",
            "description": "Reference date for the EVM snapshot (ISO 8601)."
          },
          "bac": {
            "type": "number",
            "format": "decimal",
            "description": "Budget At Completion — total estimated hours across all tasks (2 dp)."
          },
          "ev": {
            "type": "number",
            "format": "decimal",
            "description": "Earned Value — hours of done tasks (2 dp)."
          },
          "ac": {
            "type": "number",
            "format": "decimal",
            "description": "Actual Cost — hours logged via time entries up to as_of (2 dp)."
          },
          "pv": {
            "type": "number",
            "format": "decimal",
            "description": "Planned Value — schedule-fraction of BAC up to as_of (2 dp)."
          },
          "cpi": {
            "type": [
              "number",
              "null"
            ],
            "format": "decimal",
            "description": "Cost Performance Index (EV/AC); null when AC == 0 (2 dp)."
          },
          "spi": {
            "type": [
              "number",
              "null"
            ],
            "format": "decimal",
            "description": "Schedule Performance Index (EV/PV); null when PV == 0 (2 dp)."
          },
          "eac": {
            "type": "number",
            "format": "decimal",
            "description": "Estimate At Completion (BAC/CPI or BAC when CPI null) (2 dp)."
          },
          "etc": {
            "type": "number",
            "format": "decimal",
            "description": "Estimate To Complete (EAC − AC) (2 dp)."
          },
          "vac": {
            "type": "number",
            "format": "decimal",
            "description": "Variance At Completion (BAC − EAC) (2 dp)."
          },
          "pv_note": {
            "type": [
              "string",
              "null"
            ],
            "description": "Explains why PV is 0 (e.g. no task has a due_date). Null when PV is normally computed."
          }
        }
      },
      "TaskAttachment": {
        "type": "object",
        "title": "TaskAttachment",
        "description": "Metadatos de un adjunto cargado en una tarea. El contenido binario se obtiene con `downloadTaskAttachment`.",
        "required": [
          "id",
          "organization_id",
          "project_id",
          "task_id",
          "filename",
          "content_type",
          "size_bytes",
          "uploaded_by",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "a1b2c3d4-e5f6-4789-abcd-ef0123456789"
            ]
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "project_id": {
            "type": "string",
            "format": "uuid"
          },
          "task_id": {
            "type": "string",
            "format": "uuid"
          },
          "filename": {
            "type": "string",
            "description": "Nombre original del fichero tal como lo subió el usuario.",
            "examples": [
              "diseño-v2.png"
            ]
          },
          "content_type": {
            "type": "string",
            "description": "MIME type del fichero.",
            "examples": [
              "image/png"
            ]
          },
          "size_bytes": {
            "type": "integer",
            "description": "Tamaño en bytes del fichero almacenado.",
            "examples": [
              204800
            ]
          },
          "uploaded_by": {
            "type": "string",
            "format": "uuid",
            "description": "UUID del usuario que subió el fichero."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-10T09:00:00Z"
            ]
          }
        }
      },
      "CustomFieldDef": {
        "type": "object",
        "title": "CustomFieldDef",
        "description": "Definición de un campo personalizado creada por un admin. Aplica a todas las tareas de la organización.",
        "required": [
          "id",
          "organization_id",
          "entity",
          "name",
          "field_type",
          "options",
          "position",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "d1e2f3a4-b5c6-47d8-9e0f-1a2b3c4d5e6f"
            ]
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "entity": {
            "type": "string",
            "description": "Tipo de entidad al que aplica el campo; actualmente siempre `task`.",
            "examples": [
              "task"
            ]
          },
          "name": {
            "type": "string",
            "description": "Nombre visible del campo.",
            "examples": [
              "Presupuesto estimado"
            ]
          },
          "field_type": {
            "type": "string",
            "description": "Tipo del valor que acepta el campo.",
            "enum": [
              "text",
              "number",
              "date",
              "select",
              "url",
              "email"
            ]
          },
          "options": {
            "type": [
              "array",
              "null"
            ],
            "description": "Opciones válidas para campos `select`; `null` para otros tipos.",
            "items": {
              "type": "string"
            },
            "examples": [
              [
                "Pendiente",
                "En curso",
                "Completado"
              ]
            ]
          },
          "position": {
            "type": "integer",
            "description": "Orden de visualización (ascendente).",
            "examples": [
              0
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-10T09:00:00Z"
            ]
          }
        }
      },
      "CustomFieldValue": {
        "type": "object",
        "title": "CustomFieldValue",
        "description": "Valor de un `CustomFieldDef` aplicado a una tarea. Incluye metadatos del campo para uso directo en UI.",
        "required": [
          "id",
          "def_id",
          "task_id",
          "name",
          "field_type",
          "value"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID del registro de valor.",
            "examples": [
              "f6e5d4c3-b2a1-4098-8765-4fedcba98765"
            ]
          },
          "def_id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID de la definición del campo."
          },
          "task_id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID de la tarea a la que pertenece el valor."
          },
          "name": {
            "type": "string",
            "description": "Nombre del campo (copiado de la definición para uso directo).",
            "examples": [
              "Presupuesto estimado"
            ]
          },
          "field_type": {
            "type": "string",
            "description": "Tipo del campo (copiado de la definición).",
            "enum": [
              "text",
              "number",
              "date",
              "select",
              "url",
              "email"
            ]
          },
          "value": {
            "type": "string",
            "description": "Valor almacenado siempre como string; el cliente interpreta según `field_type`.",
            "examples": [
              "1500"
            ]
          }
        }
      },
      "Contract": {
        "type": "object",
        "title": "Contract",
        "description": "Contrato org-scoped con numeración CT-YYYY-NNNN.",
        "required": [
          "id",
          "organization_id",
          "contract_number",
          "title",
          "contract_type",
          "status",
          "client_id",
          "employee_id",
          "value",
          "currency",
          "start_date",
          "end_date",
          "body",
          "notes",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "contract_number": {
            "type": "string",
            "description": "Número correlativo auto-generado (CT-YYYY-NNNN por org y año).",
            "examples": [
              "CT-2026-0001"
            ]
          },
          "title": {
            "type": "string"
          },
          "contract_type": {
            "type": "string",
            "enum": [
              "service",
              "employment",
              "nda",
              "framework",
              "lease",
              "other"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "active",
              "signed",
              "expired",
              "terminated",
              "cancelled"
            ]
          },
          "client_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "employee_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "value": {
            "type": [
              "number",
              "null"
            ]
          },
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 del contrato. Siempre viene informada: si el alta no la declaró, el servidor guardó la divisa BASE de la organización. El `default: EUR` que había aquí no era cierto para nadie que no facture en euros — y esto es una RESPUESTA, donde un default solo puede confundir.",
            "examples": [
              "EUR"
            ]
          },
          "start_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "end_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "body": {
            "type": [
              "string",
              "null"
            ],
            "description": "Cuerpo del contrato (puede contener {{variables}})."
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ContractCreate": {
        "type": "object",
        "title": "ContractCreate",
        "required": [
          "title",
          "contract_type"
        ],
        "properties": {
          "title": {
            "type": "string"
          },
          "contract_type": {
            "type": "string",
            "enum": [
              "service",
              "employment",
              "nda",
              "framework",
              "lease",
              "other"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "active",
              "signed",
              "expired",
              "terminated",
              "cancelled"
            ],
            "default": "draft"
          },
          "client_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "employee_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "value": {
            "type": [
              "number",
              "null"
            ]
          },
          "currency": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 del contrato. Omitida o `null` = la divisa BASE de la organización, que resuelve el servidor. Antes el default era el literal `EUR` declarado aquí, y el schema es justo la capa que no sabe de qué organización viene la petición: un contrato de una filial estadounidense nacía en euros sin que nadie lo pidiera ni pudiera cambiarlo.",
            "examples": [
              "USD"
            ]
          },
          "start_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "end_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "body": {
            "type": [
              "string",
              "null"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "ContractUpdate": {
        "type": "object",
        "title": "ContractUpdate",
        "properties": {
          "title": {
            "type": [
              "string",
              "null"
            ]
          },
          "contract_type": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "service",
              "employment",
              "nda",
              "framework",
              "lease",
              "other"
            ]
          },
          "status": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "draft",
              "active",
              "signed",
              "expired",
              "terminated",
              "cancelled"
            ]
          },
          "client_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "employee_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "value": {
            "type": [
              "number",
              "null"
            ]
          },
          "currency": {
            "type": [
              "string",
              "null"
            ]
          },
          "start_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "end_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "body": {
            "type": [
              "string",
              "null"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "ContractTemplate": {
        "type": "object",
        "title": "ContractTemplate",
        "required": [
          "id",
          "organization_id",
          "name",
          "body",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "body": {
            "type": "string",
            "description": "Cuerpo de la plantilla (puede contener {{variables}})."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ContractTemplateCreate": {
        "type": "object",
        "title": "ContractTemplateCreate",
        "required": [
          "name",
          "body"
        ],
        "properties": {
          "name": {
            "type": "string"
          },
          "body": {
            "type": "string"
          }
        }
      },
      "ContractTemplateUpdate": {
        "type": "object",
        "title": "ContractTemplateUpdate",
        "properties": {
          "name": {
            "type": [
              "string",
              "null"
            ]
          },
          "body": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "ContractMilestone": {
        "type": "object",
        "title": "ContractMilestone",
        "required": [
          "id",
          "organization_id",
          "contract_id",
          "title",
          "amount",
          "due_date",
          "status",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "contract_id": {
            "type": "string",
            "format": "uuid"
          },
          "title": {
            "type": "string"
          },
          "amount": {
            "type": "number",
            "description": "Importe del hito."
          },
          "due_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "invoiced",
              "paid"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ContractMilestoneCreate": {
        "type": "object",
        "title": "ContractMilestoneCreate",
        "required": [
          "title",
          "amount"
        ],
        "properties": {
          "title": {
            "type": "string"
          },
          "amount": {
            "type": "number"
          },
          "due_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "invoiced",
              "paid"
            ],
            "default": "pending"
          }
        }
      },
      "ContractMilestoneUpdate": {
        "type": "object",
        "title": "ContractMilestoneUpdate",
        "properties": {
          "title": {
            "type": [
              "string",
              "null"
            ]
          },
          "amount": {
            "type": [
              "number",
              "null"
            ]
          },
          "due_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "status": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "pending",
              "invoiced",
              "paid"
            ]
          }
        }
      },
      "BiDashboard": {
        "type": "object",
        "title": "BiDashboard",
        "required": [
          "id",
          "organization_id",
          "name",
          "created_by",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "created_by": {
            "type": "string",
            "format": "uuid"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "BiDashboardCreate": {
        "type": "object",
        "title": "BiDashboardCreate",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string"
          }
        }
      },
      "BiDashboardUpdate": {
        "type": "object",
        "title": "BiDashboardUpdate",
        "properties": {
          "name": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "BiFilter": {
        "type": "object",
        "title": "BiFilter",
        "description": "Cláusula de filtro de una consulta de BI (`column op value`).",
        "required": [
          "column",
          "op",
          "value"
        ],
        "properties": {
          "column": {
            "type": "string",
            "description": "Columna de la fuente de datos por la que se filtra. Se comprueba contra la lista blanca de la `data_source`; una columna desconocida es `422`.",
            "examples": [
              "status"
            ]
          },
          "op": {
            "type": "string",
            "enum": [
              "eq",
              "ne",
              "gt",
              "lt",
              "in"
            ],
            "description": "Operador de comparación. `in` espera una lista en `value`; el resto, un escalar."
          },
          "value": {
            "description": "Valor con el que se compara. Sin tipo fijo a propósito: depende de la columna (texto, número, fecha ISO, booleano) y con `op: in` es una lista.",
            "examples": [
              "done"
            ]
          }
        }
      },
      "BiPosition": {
        "type": "object",
        "title": "BiPosition",
        "description": "Posición y tamaño del widget en la rejilla del panel.",
        "properties": {
          "x": {
            "type": "integer",
            "description": "Columna de la rejilla donde empieza el widget. Si se omite, `0`."
          },
          "y": {
            "type": "integer",
            "description": "Fila de la rejilla donde empieza el widget. Si se omite, `0`."
          },
          "w": {
            "type": "integer",
            "description": "Ancho en columnas de la rejilla. Si se omite, `4`."
          },
          "h": {
            "type": "integer",
            "description": "Alto en filas de la rejilla. Si se omite, `4`."
          }
        }
      },
      "BiWidget": {
        "type": "object",
        "title": "BiWidget",
        "required": [
          "id",
          "organization_id",
          "dashboard_id",
          "title",
          "widget_type",
          "data_source",
          "dimension",
          "measure",
          "measure_field",
          "filters",
          "position",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "dashboard_id": {
            "type": "string",
            "format": "uuid"
          },
          "title": {
            "type": "string"
          },
          "widget_type": {
            "type": "string",
            "description": "Sin enum en la LECTURA a propósito. El alta y la edición sí lo restringen a `kpi`, `bar`, `line`, `pie` o `table` (422 en otro caso, ver `BiWidgetCreate`); la columna es un VARCHAR sin restricción en la base y una fila con otro valor convertía el listado del panel ENTERO en un 500. Ver `docs/propuesta-validar-al-escribir-no-al-leer.md`."
          },
          "data_source": {
            "type": "string",
            "description": "Whitelisted source — el alta, la edición y `POST /bi/query` rechazan con 422 lo que no esté en `tasks`, `invoices`, `expenses`, `projects`, `clients` o `deals`. Sin enum en la LECTURA por lo mismo que `widget_type`."
          },
          "dimension": {
            "type": [
              "string",
              "null"
            ],
            "description": "Whitelisted dimension column name."
          },
          "measure": {
            "type": "string",
            "default": "count",
            "description": "`count`, `sum` o `avg` en el alta y la edición. Sin enum en la LECTURA por lo mismo que `widget_type`."
          },
          "measure_field": {
            "type": [
              "string",
              "null"
            ],
            "description": "Required when measure is sum or avg. Whitelisted."
          },
          "filters": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "object",
              "properties": {
                "column": {
                  "type": "string"
                },
                "op": {
                  "type": "string",
                  "enum": [
                    "eq",
                    "ne",
                    "gt",
                    "lt",
                    "in"
                  ]
                },
                "value": {}
              }
            }
          },
          "position": {
            "type": [
              "object",
              "null"
            ],
            "description": "Grid position {x, y, w, h}.",
            "properties": {
              "x": {
                "type": "integer"
              },
              "y": {
                "type": "integer"
              },
              "w": {
                "type": "integer"
              },
              "h": {
                "type": "integer"
              }
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "BiWidgetCreate": {
        "type": "object",
        "title": "BiWidgetCreate",
        "required": [
          "title",
          "widget_type",
          "data_source"
        ],
        "properties": {
          "title": {
            "type": "string"
          },
          "widget_type": {
            "type": "string",
            "enum": [
              "kpi",
              "bar",
              "line",
              "pie",
              "table"
            ]
          },
          "data_source": {
            "type": "string",
            "enum": [
              "tasks",
              "invoices",
              "expenses",
              "projects",
              "clients",
              "deals"
            ]
          },
          "dimension": {
            "type": [
              "string",
              "null"
            ]
          },
          "measure": {
            "type": "string",
            "enum": [
              "count",
              "sum",
              "avg"
            ],
            "default": "count"
          },
          "measure_field": {
            "type": [
              "string",
              "null"
            ]
          },
          "filters": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "$ref": "#/components/schemas/BiFilter"
            }
          },
          "position": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/BiPosition"
              },
              {
                "type": "null"
              }
            ]
          }
        }
      },
      "BiWidgetUpdate": {
        "type": "object",
        "title": "BiWidgetUpdate",
        "properties": {
          "title": {
            "type": [
              "string",
              "null"
            ]
          },
          "widget_type": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "kpi",
              "bar",
              "line",
              "pie",
              "table"
            ]
          },
          "data_source": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "tasks",
              "invoices",
              "expenses",
              "projects",
              "clients",
              "deals"
            ]
          },
          "dimension": {
            "type": [
              "string",
              "null"
            ]
          },
          "measure": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "count",
              "sum",
              "avg"
            ]
          },
          "measure_field": {
            "type": [
              "string",
              "null"
            ]
          },
          "filters": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "$ref": "#/components/schemas/BiFilter"
            }
          },
          "position": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/BiPosition"
              },
              {
                "type": "null"
              }
            ]
          }
        }
      },
      "BiWidgetData": {
        "type": "object",
        "title": "BiWidgetData",
        "description": "Result rows from a BI widget query.",
        "required": [
          "widget_id",
          "data_source",
          "dimension",
          "measure",
          "rows"
        ],
        "properties": {
          "widget_id": {
            "type": "string",
            "format": "uuid"
          },
          "data_source": {
            "type": "string"
          },
          "dimension": {
            "type": [
              "string",
              "null"
            ]
          },
          "measure": {
            "type": "string"
          },
          "currency": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 de los valores, o `null`. Se rellena cuando el agregado es un IMPORTE (`invoices.total`, `expenses.amount`, `deals.value`): entonces la consulta se acota a la divisa base de la organización, porque sumar euros con dólares produce un número que no es dinero. Es `null` cuando la pregunta no es de dinero (un `count`, `deals.is_won`) o cuando el propio widget agrupa o filtra por `currency` y cada fila ya habla por sí misma. Nunca se convierte nada.",
            "examples": [
              "USD"
            ]
          },
          "rows": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "value"
              ],
              "properties": {
                "dimension_value": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "value": {
                  "description": "Aggregate result (count/sum/avg)."
                }
              }
            }
          }
        }
      },
      "BiAdHocQuery": {
        "type": "object",
        "title": "BiAdHocQuery",
        "description": "Ad-hoc BI query spec (without saving a widget).",
        "required": [
          "data_source"
        ],
        "properties": {
          "data_source": {
            "type": "string",
            "enum": [
              "tasks",
              "invoices",
              "expenses",
              "projects",
              "clients",
              "deals"
            ]
          },
          "dimension": {
            "type": [
              "string",
              "null"
            ]
          },
          "measure": {
            "type": "string",
            "enum": [
              "count",
              "sum",
              "avg"
            ],
            "default": "count"
          },
          "measure_field": {
            "type": [
              "string",
              "null"
            ]
          },
          "filters": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "$ref": "#/components/schemas/BiFilter"
            }
          }
        }
      },
      "TaskCreate": {
        "type": "object",
        "title": "TaskCreate",
        "description": "Payload para crear una tarea (normal o cross-tenant en proyecto compartido).",
        "required": [
          "title"
        ],
        "properties": {
          "title": {
            "type": "string",
            "description": "Título de la tarea."
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "description": "Descripción libre (markdown)."
          },
          "priority": {
            "type": "string",
            "enum": [
              "low",
              "medium",
              "high",
              "urgent"
            ],
            "default": "medium"
          },
          "type": {
            "type": "string",
            "enum": [
              "epic",
              "story",
              "task",
              "bug",
              "spike",
              "chore"
            ],
            "default": "task"
          },
          "assignee_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Miembro al que asignar la tarea AL CREARLA; `null` u omitido = sin asignar. En un proyecto compartido se valida contra la organización HOST (miembro del host o colaborador externo activo del proyecto) → 422 si no lo es.\nEstaba sin declarar mientras el API lo aceptaba y lo honraba (y la propia descripción de `createSharedTask` lo prometía), así que ningún SDK podía asignar una tarea del proyecto compartido en el alta: había que crearla y luego hacer un PATCH."
          },
          "story_points": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0
          },
          "estimated_hours": {
            "type": [
              "string",
              "null"
            ],
            "description": "Estimación en horas (DECIMAL serializado como string, p.ej. \"12.50\")."
          },
          "start_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "due_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "due_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Vencimiento con hora (ISO 8601; sin zona se interpreta como UTC). Si se envía sin `due_date`, esta se deriva de su fecha local en la zona de la organización para que los listados y filtros por fecha sigan cuadrando."
          },
          "completed_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fecha de finalización real (ISO `YYYY-MM-DD`); normalmente se deja vacía y se autoasigna al pasar a `done`."
          },
          "sprint_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID de la tarea padre (para subtareas)."
          }
        }
      },
      "SharedTaskUpdate": {
        "type": "object",
        "title": "SharedTaskUpdate",
        "description": "Payload PATCH restringido para actualizar una tarea en un proyecto compartido (cross-tenant). Solo se permiten los cinco campos whitelisteados; el resto se ignora en el backend.",
        "properties": {
          "status": {
            "type": [
              "string",
              "null"
            ],
            "description": "Estado de la tarea, con el MISMO conjunto abierto que `Task.status`: además de los 4 base (todo/in_progress/done/cancelled) puede ser un estado PERSONALIZADO de columna de tablero (slug `^[a-z0-9_]+$`). Aquí declaraba `enum: [todo, in_progress, done]`, que el API nunca ha impuesto: el panel de un proyecto compartido ofrece las seis columnas por defecto y tenía que castear el valor para que el SDK lo dejara pasar. Un enum cerrado no puede valer para un conjunto que define cada tablero."
          },
          "priority": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "low",
              "medium",
              "high",
              "urgent"
            ]
          },
          "assignee_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del miembro asignado; null para desasignar."
          },
          "title": {
            "type": [
              "string",
              "null"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "AutomationCondition": {
        "type": "object",
        "title": "AutomationCondition",
        "description": "Una condición individual de una regla de automatización. El campo `field` solo puede ser uno de los valores whitelisteados (status, priority, type, project_id, assignee_id); `op` es eq | ne | in; `value` es un valor literal o lista (para \"in\").",
        "required": [
          "field",
          "op",
          "value"
        ],
        "properties": {
          "field": {
            "type": "string",
            "enum": [
              "status",
              "priority",
              "type",
              "project_id",
              "assignee_id"
            ],
            "description": "Campo de la tarea sobre el que evaluar la condición."
          },
          "op": {
            "type": "string",
            "enum": [
              "eq",
              "ne",
              "in"
            ],
            "description": "Operador de comparación."
          },
          "value": {
            "description": "Valor a comparar. Para `op=in` debe ser un array; para `eq`/`ne` un escalar (string o número)."
          }
        }
      },
      "AutomationRule": {
        "type": "object",
        "title": "AutomationRule",
        "description": "Regla de automatización org-scoped (Wave I1).",
        "required": [
          "id",
          "organization_id",
          "name",
          "trigger_event",
          "conditions",
          "action",
          "action_params",
          "is_active",
          "created_by",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "description": "Nombre legible de la regla."
          },
          "trigger_event": {
            "type": "string",
            "description": "Evento del ciclo de vida de la tarea que dispara la regla. El ALTA y la EDICIÓN sí lo restringen a `task_created`, `task_status_changed` o `task_assigned` (422 en otro caso, ver `AutomationRuleCreate`); en la LECTURA no se declara enum a propósito. La columna es un VARCHAR sin restricción en la base y una fila con otro valor —SQL a mano, un volcado viejo, un valor retirado del enum— convertía el listado ENTERO de la organización en un 500. Ver `docs/propuesta-validar-al-escribir-no-al-leer.md`."
          },
          "conditions": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "$ref": "#/components/schemas/AutomationCondition"
            },
            "maxItems": 10,
            "description": "Lista de condiciones (AND lógico). Si es null o vacía la regla siempre aplica."
          },
          "action": {
            "type": "string",
            "description": "Acción que ejecuta el motor cuando las condiciones hacen match. Sin enum en la LECTURA por lo mismo que `trigger_event`; el alta y la edición lo restringen a `set_priority`, `set_status`, `assign`, `add_comment` o `notify`."
          },
          "action_params": {
            "type": [
              "object",
              "null"
            ],
            "description": "Parámetros de la acción. Ejemplos: `{\"priority\":\"high\"}`, `{\"status\":\"in_progress\"}`, `{\"assignee_id\":\"<uuid>\"}`, `{\"comment\":\"texto\"}`.",
            "additionalProperties": true
          },
          "is_active": {
            "type": "boolean",
            "default": true,
            "description": "Si false el motor ignora esta regla."
          },
          "created_by": {
            "type": "string",
            "format": "uuid",
            "description": "UUID del usuario que creó la regla."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "AutomationRuleCreate": {
        "type": "object",
        "title": "AutomationRuleCreate",
        "description": "Payload para crear o reemplazar una regla de automatización.",
        "required": [
          "name",
          "trigger_event",
          "action"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "Nombre legible de la regla."
          },
          "trigger_event": {
            "type": "string",
            "enum": [
              "task_created",
              "task_status_changed",
              "task_assigned"
            ]
          },
          "conditions": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "$ref": "#/components/schemas/AutomationCondition"
            },
            "maxItems": 10,
            "description": "Condiciones (AND). null o vacía = siempre aplica."
          },
          "action": {
            "type": "string",
            "enum": [
              "set_priority",
              "set_status",
              "assign",
              "add_comment",
              "notify"
            ]
          },
          "action_params": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true,
            "description": "Parámetros de la acción (ver AutomationRule)."
          },
          "is_active": {
            "type": "boolean",
            "default": true
          }
        }
      },
      "AutomationRuleUpdate": {
        "type": "object",
        "title": "AutomationRuleUpdate",
        "description": "Payload PATCH parcial para actualizar una regla de automatización.",
        "properties": {
          "name": {
            "type": [
              "string",
              "null"
            ]
          },
          "trigger_event": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "task_created",
              "task_status_changed",
              "task_assigned"
            ]
          },
          "conditions": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "$ref": "#/components/schemas/AutomationCondition"
            },
            "maxItems": 10
          },
          "action": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "set_priority",
              "set_status",
              "assign",
              "add_comment",
              "notify"
            ]
          },
          "action_params": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true
          },
          "is_active": {
            "type": [
              "boolean",
              "null"
            ]
          }
        }
      },
      "AutomationActionResult": {
        "type": "object",
        "title": "AutomationActionResult",
        "description": "Resultado de evaluar una regla en modo dry-run (sin escrituras). `would_apply` es true solo si `conditions_matched` es true.",
        "required": [
          "rule_id",
          "rule_name",
          "conditions_matched",
          "action",
          "action_params",
          "would_apply",
          "description"
        ],
        "properties": {
          "rule_id": {
            "type": "string",
            "format": "uuid"
          },
          "rule_name": {
            "type": "string"
          },
          "conditions_matched": {
            "type": "boolean",
            "description": "true si todas las condiciones hicieron match contra la tarea dada."
          },
          "action": {
            "type": "string",
            "description": "La acción guardada en la regla. Sin enum: sale de la misma columna que `AutomationRule.action` y por el mismo motivo (ver ahí)."
          },
          "action_params": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true
          },
          "would_apply": {
            "type": "boolean",
            "description": "true si el motor aplicaría la acción (= conditions_matched)."
          },
          "description": {
            "type": "string",
            "description": "Descripción legible de lo que haría el motor. Vacía si would_apply es false."
          }
        }
      },
      "WeatherCondition": {
        "type": "object",
        "title": "WeatherCondition",
        "description": "Condición meteorológica actual para las coordenadas del usuario. Si `unavailable` es `true`, el proveedor externo no está disponible pero el endpoint siempre devuelve HTTP 200. `is_override` indica que se está devolviendo el pin manual del usuario en lugar del valor en vivo.",
        "required": [
          "is_override",
          "unavailable"
        ],
        "properties": {
          "condition": {
            "type": [
              "string",
              "null"
            ],
            "description": "Condición meteorológica simplificada. `null` cuando el proveedor no está disponible y no hay override.",
            "enum": [
              "clear",
              "clouds",
              "rain",
              "snow",
              "thunder",
              "fog",
              null
            ],
            "examples": [
              "clear"
            ]
          },
          "temperature": {
            "type": [
              "number",
              "null"
            ],
            "description": "Temperatura actual en grados Celsius; `null` si no disponible.",
            "examples": [
              22.3
            ]
          },
          "temp_min": {
            "type": [
              "number",
              "null"
            ],
            "description": "Temperatura mínima del día en grados Celsius.",
            "examples": [
              18.1
            ]
          },
          "temp_max": {
            "type": [
              "number",
              "null"
            ],
            "description": "Temperatura máxima del día en grados Celsius.",
            "examples": [
              26.5
            ]
          },
          "is_override": {
            "type": "boolean",
            "description": "`true` cuando se devuelve `users.weather_override` (pin manual) en lugar del valor en vivo de Open-Meteo."
          },
          "unavailable": {
            "type": "boolean",
            "description": "`true` cuando la llamada a Open-Meteo falló (timeout, error de red, etc.). El endpoint siempre devuelve HTTP 200; el cliente debe mostrar un estado degradado."
          },
          "forecast": {
            "type": "array",
            "description": "Previsión de los próximos días (hasta 5) — un item por día (PJKT-1877). Vacía si el proveedor no está disponible.",
            "items": {
              "type": "object",
              "title": "WeatherForecastDay",
              "required": [
                "date"
              ],
              "properties": {
                "date": {
                  "type": "string",
                  "format": "date",
                  "examples": [
                    "2026-07-20"
                  ]
                },
                "condition": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "Condición del día; `null` si Open-Meteo no dio código para ese día.",
                  "enum": [
                    "clear",
                    "clouds",
                    "rain",
                    "snow",
                    "thunder",
                    "fog",
                    null
                  ]
                },
                "temp_max": {
                  "type": [
                    "number",
                    "null"
                  ],
                  "examples": [
                    27.4
                  ]
                },
                "temp_min": {
                  "type": [
                    "number",
                    "null"
                  ],
                  "examples": [
                    17.9
                  ]
                }
              }
            }
          }
        }
      },
      "AgencyClientRow": {
        "type": "object",
        "title": "AgencyClientRow",
        "description": "Métricas de un cliente en el resumen de agencia. `projects_count` = contratos org-scoped vinculados al cliente. `invoiced_total` y `outstanding_total` agrupan las facturas por `invoices.client_id` y solo caen a la coincidencia de texto `invoices.client_name = clients.name` cuando la factura no tiene ficha vinculada (ver `schema_note`).",
        "required": [
          "client_id",
          "client_name",
          "projects_count",
          "open_tasks",
          "invoiced_total",
          "outstanding_total"
        ],
        "properties": {
          "client_id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID del cliente."
          },
          "client_name": {
            "type": "string",
            "description": "Nombre del cliente."
          },
          "projects_count": {
            "type": "integer",
            "description": "Número de contratos de la org vinculados a este cliente (mejor aproximación a proyectos disponible en el esquema actual)."
          },
          "open_tasks": {
            "type": "integer",
            "description": "Tareas abiertas en proyectos de este cliente. Actualmente siempre 0 porque `projects` no tiene FK directa a `clients`; ver `schema_note`."
          },
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 de los dos importes de esta fila — la base de la organización. Solo se agregan facturas emitidas en ella: antes era un `SUM(total)` ciego a la divisa, o sea euros y dólares en un mismo número presentado como «lo facturado a este cliente». No se convierte nada (no hay tipos de cambio en el producto); lo emitido en otra divisa queda fuera.",
            "examples": [
              "USD"
            ]
          },
          "invoiced_total": {
            "type": "string",
            "format": "decimal",
            "description": "Suma de las facturas EMITIDAS (`sent`, `paid`, `overdue`) de este cliente. Excluye borradores y anuladas. Filtraba por un estado `approved` que no existe en el enum —legado del PHP, remapeado a `sent` al importar—, así que en la práctica solo sumaba las cobradas y esta cifra enseñaba lo COBRADO. Y se agrupaba por nombre, así que renombrar la ficha la ponía a 0.",
            "examples": [
              "15000.00"
            ]
          },
          "outstanding_total": {
            "type": "string",
            "format": "decimal",
            "description": "Suma de las facturas emitidas y no cobradas (`sent`, `overdue`) de este cliente. Es un SUBCONJUNTO de `invoiced_total`: nunca puede ser mayor que él.",
            "examples": [
              "3500.00"
            ]
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL del logo del cliente (puede ser null si no se ha configurado)."
          },
          "schema_note": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nota sobre el criterio de atribución de facturas (FK `client_id`, con el nombre solo como respaldo) y sobre la ausencia de FK `projects`→`clients`."
          }
        }
      },
      "AgencyOverview": {
        "type": "object",
        "title": "AgencyOverview",
        "description": "Resumen multi-cliente para organizaciones que gestionan varios clientes. Incluye métricas por cliente y totales globales de la organización.",
        "required": [
          "clients",
          "org_projects_total",
          "org_open_tasks_total",
          "org_invoiced_total",
          "org_outstanding_total"
        ],
        "properties": {
          "clients": {
            "type": "array",
            "description": "Lista de clientes con sus métricas (cap 200).",
            "items": {
              "$ref": "#/components/schemas/AgencyClientRow"
            }
          },
          "org_projects_total": {
            "type": "integer",
            "description": "Total de contratos con client_id en la org (proxy de proyectos)."
          },
          "org_open_tasks_total": {
            "type": "integer",
            "description": "Total de tareas abiertas en proyectos con cliente asignado. Actualmente 0 (sin FK projects→clients)."
          },
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "Divisa ISO 4217 de los dos totales de la organización — su divisa base. Solo se agregan facturas emitidas en ella; no se convierte nada. Es la misma que declara cada fila de `clients`.",
            "examples": [
              "USD"
            ]
          },
          "org_invoiced_total": {
            "type": "string",
            "format": "decimal",
            "description": "Suma de las facturas EMITIDAS (`sent`, `paid`, `overdue`) de toda la org. Excluye borradores y anuladas.",
            "examples": [
              "120000.00"
            ]
          },
          "org_outstanding_total": {
            "type": "string",
            "format": "decimal",
            "description": "Suma de las facturas emitidas y no cobradas (`sent`, `overdue`) de toda la org. Subconjunto de `org_invoiced_total`, nunca mayor que él.",
            "examples": [
              "28500.00"
            ]
          }
        }
      },
      "Holding": {
        "type": "object",
        "title": "Holding",
        "description": "Grupo empresarial: una organización MATRIZ (`parent_org_id`, que es una organización NORMAL) más las filiales que cuelgan de ella. El acceso al holding NO se deriva del rol en la matriz: exige fila propia en `holding_members` (`my_role`). `can_manage` indica si el usuario puede adjuntar/soltar filiales y gestionar miembros — para eso hay que ser `owner` de la organización MATRIZ.",
        "required": [
          "id",
          "name",
          "parent_org_id",
          "parent_org_name",
          "my_role",
          "can_manage",
          "subsidiaries_count",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "maxLength": 255,
            "examples": [
              "Grupo 3XA"
            ]
          },
          "parent_org_id": {
            "type": "string",
            "format": "uuid",
            "description": "Organización MATRIZ del holding."
          },
          "parent_org_name": {
            "type": "string",
            "description": "Nombre de la organización matriz."
          },
          "my_role": {
            "type": "string",
            "enum": [
              "owner",
              "viewer"
            ],
            "description": "Rol del usuario que consulta dentro de `holding_members`."
          },
          "can_manage": {
            "type": "boolean",
            "description": "True si el usuario es `owner` de la organización MATRIZ y, por tanto, puede adjuntar/soltar filiales y gestionar los miembros del holding."
          },
          "subsidiaries_count": {
            "type": "integer",
            "description": "Número de FILIALES (no incluye la matriz)."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "HoldingCreate": {
        "type": "object",
        "title": "HoldingCreate",
        "description": "Crea un holding cuya MATRIZ es una organización existente del usuario. Solo el `owner` de esa organización puede crearlo. Requiere la feature `holdings` (plan Enterprise) en la matriz; las filiales conservan su propio plan.",
        "required": [
          "name",
          "parent_org_id"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255,
            "examples": [
              "Grupo 3XA"
            ]
          },
          "parent_org_id": {
            "type": "string",
            "format": "uuid",
            "description": "Organización que actuará como MATRIZ del holding."
          }
        }
      },
      "HoldingUpdate": {
        "type": "object",
        "title": "HoldingUpdate",
        "description": "Renombra el holding. Solo el `owner` de la organización MATRIZ.",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255
          }
        }
      },
      "HoldingSubsidiaryAttach": {
        "type": "object",
        "title": "HoldingSubsidiaryAttach",
        "description": "Organización que se adjunta al holding. DOBLE LLAVE de autorización: el caller debe ser `owner` de la organización MATRIZ **y** `owner`/`admin` de la organización que adjunta — no se puede absorber una organización ajena conociendo su UUID.",
        "required": [
          "organization_id"
        ],
        "properties": {
          "organization_id": {
            "type": "string",
            "format": "uuid"
          }
        }
      },
      "HoldingMember": {
        "type": "object",
        "title": "HoldingMember",
        "description": "Fila de acceso al consolidado. Deliberadamente SIN PII: solo `user_id` y rol (decisión de producto: el panel de holding no expone datos de personas).",
        "required": [
          "id",
          "holding_id",
          "user_id",
          "role",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "holding_id": {
            "type": "string",
            "format": "uuid"
          },
          "user_id": {
            "type": "string",
            "format": "uuid"
          },
          "role": {
            "type": "string",
            "enum": [
              "owner",
              "viewer"
            ],
            "description": "`owner` puede gestionar el holding (si además es `owner` de la matriz); `viewer` solo ve el consolidado."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "HoldingMemberCreate": {
        "type": "object",
        "title": "HoldingMemberCreate",
        "description": "Da acceso al consolidado a un usuario. El usuario debe pertenecer a alguna organización del grupo (404 en caso contrario): el acceso consolidado no se concede a un usuario ajeno por conocer su UUID.",
        "required": [
          "user_id"
        ],
        "properties": {
          "user_id": {
            "type": "string",
            "format": "uuid"
          },
          "role": {
            "type": "string",
            "enum": [
              "owner",
              "viewer"
            ],
            "default": "viewer"
          }
        }
      },
      "HoldingMemberUpdate": {
        "type": "object",
        "title": "HoldingMemberUpdate",
        "description": "Cambia el rol. 409 `last_holding_owner` si dejaría al holding sin ningún `owner`.",
        "required": [
          "role"
        ],
        "properties": {
          "role": {
            "type": "string",
            "enum": [
              "owner",
              "viewer"
            ]
          }
        }
      },
      "HoldingVisibility": {
        "type": "object",
        "title": "HoldingVisibility",
        "description": "Qué BLOQUES de datos de esta filial entran en el consolidado del holding. Es una máscara puramente RESTRICTIVA: se aplica sobre las organizaciones que el servidor ya había resuelto desde `organizations.holding_id`, quitando. Ningún valor de esta configuración puede hacer visible una organización ajena al holding, ni convertir la lectura consolidada en escritura.\n\nPor defecto TODO está visible: una filial sin configuración guardada devuelve los tres bloques a `true` y `hidden_blocks` vacío (es el comportamiento previo a esta feature, intacto).",
        "required": [
          "organization_id",
          "organization_name",
          "finance",
          "projects",
          "people",
          "hidden_blocks"
        ],
        "properties": {
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "description": "Filial a la que aplica (nunca la organización MATRIZ)."
          },
          "organization_name": {
            "type": "string"
          },
          "finance": {
            "type": "boolean",
            "description": "Ingresos, gastos y neto de esta filial en `GET /holdings/{holding_id}/finance`. Con `false` su fila viaja sin ningún importe y no suma en los totales."
          },
          "projects": {
            "type": "boolean",
            "description": "Proyectos Y tareas de esta filial en `GET /holdings/{holding_id}/summary`. Son un solo bloque: ver las tareas de una filial cuyos proyectos están ocultos no tendría sentido."
          },
          "people": {
            "type": "boolean",
            "description": "Recuento de personas de esta filial en el resumen consolidado."
          },
          "hidden_blocks": {
            "type": "array",
            "description": "Bloques ocultos, en orden estable (`finance`, `projects`, `people`). Redundante con los tres booleanos; es lo que el panel pinta directamente.",
            "items": {
              "type": "string",
              "enum": [
                "finance",
                "projects",
                "people"
              ]
            },
            "examples": [
              [
                "finance"
              ]
            ]
          }
        }
      },
      "HoldingVisibilityUpdate": {
        "type": "object",
        "title": "HoldingVisibilityUpdate",
        "description": "PATCH parcial: los bloques que no se envían conservan su valor. Solo booleanos — no hay ningún campo de organización ni de holding en el body, porque el «de quién» sale del path y se verifica contra el contexto resuelto en servidor; así este payload no puede ensanchar el alcance de nadie.\n\nAUTORIZACIÓN: solo el `owner` de la organización MATRIZ.\n\nLos tres aceptan `null`, que el servidor trata EXACTAMENTE como omitir el campo (`payload.finance is None` → conserva el valor actual). Se declara para que el contrato diga lo que el API acepta: `null` aquí no borra nada.",
        "properties": {
          "finance": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Finanzas de la filial en el consolidado; `null` = no tocar."
          },
          "projects": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Proyectos y tareas de la filial en el consolidado; `null` = no tocar."
          },
          "people": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Recuento de personas de la filial en el consolidado; `null` = no tocar."
          }
        }
      },
      "HoldingOrgRow": {
        "type": "object",
        "title": "HoldingOrgRow",
        "description": "Contadores de UNA organización del grupo. Es lo que pinta la tabla de filiales del panel. `people` son las personas con membresía en ESTA organización: la suma de `people` de todas las filas puede superar `people_total` del consolidado, porque una persona con membresía en dos filiales aparece en las dos filas pero cuenta UNA vez en el total.\n\nVISIBILIDAD: si un bloque aparece en `hidden_blocks`, sus contadores valen 0 y NO han sumado en los totales del consolidado. Un 0 con el bloque oculto significa «no lo miras», no «no hay ninguno»: hay que leer `hidden_blocks` para distinguirlo.",
        "required": [
          "organization_id",
          "name",
          "slug",
          "plan",
          "is_parent",
          "projects_total",
          "projects_active",
          "tasks_total",
          "tasks_open",
          "people",
          "hidden_blocks"
        ],
        "properties": {
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "slug": {
            "type": "string"
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Logotipo de la organización (null si no tiene)."
          },
          "plan": {
            "type": "string",
            "description": "Edición de ESTA organización (free | pro). Cada filial sigue pagando su propio plan: el holding no agrupa facturación."
          },
          "is_parent": {
            "type": "boolean",
            "description": "True para la organización MATRIZ del holding."
          },
          "projects_total": {
            "type": "integer"
          },
          "projects_active": {
            "type": "integer",
            "description": "Proyectos con `status = active`."
          },
          "tasks_total": {
            "type": "integer"
          },
          "tasks_open": {
            "type": "integer",
            "description": "Tareas cuyo estado no es `done` ni `cancelled`."
          },
          "people": {
            "type": "integer",
            "description": "Personas con membresía en esta organización."
          },
          "hidden_blocks": {
            "type": "array",
            "description": "Bloques que el holding ha ocultado para esta organización. Vacío = todo visible (estado por defecto). Los contadores de un bloque oculto valen 0 y no suman en los totales del consolidado.",
            "items": {
              "type": "string",
              "enum": [
                "finance",
                "projects",
                "people"
              ]
            }
          }
        }
      },
      "HoldingSummary": {
        "type": "object",
        "title": "HoldingSummary",
        "description": "Consolidado de actividad del grupo (matriz + filiales). Los ids de las organizaciones consolidadas se resuelven EN SERVIDOR desde `organizations.holding_id`; nunca llegan del cliente. Soltar una filial la saca del consolidado de inmediato.\n\n`people_total` cuenta usuarios DISTINTOS (`DISTINCT user_id`): quien tenga membresía en dos filiales cuenta UNA sola vez, por lo que NO coincide con la suma de `people` de cada fila. Sin PII de empleados: solo agregados.\n\nVISIBILIDAD: los totales suman SOLO las organizaciones cuyo bloque está visible. Es deliberado — si una filial oculta siguiera sumando, su dato se reconstruiría restando el total menos las filas visibles. `has_hidden_data` avisa de que los totales son parciales.",
        "required": [
          "holding_id",
          "holding_name",
          "parent_org_id",
          "subsidiaries_count",
          "organizations_count",
          "projects_total",
          "projects_active",
          "tasks_total",
          "tasks_open",
          "tasks_by_status",
          "people_total",
          "organizations",
          "has_hidden_data"
        ],
        "properties": {
          "holding_id": {
            "type": "string",
            "format": "uuid"
          },
          "holding_name": {
            "type": "string"
          },
          "parent_org_id": {
            "type": "string",
            "format": "uuid"
          },
          "subsidiaries_count": {
            "type": "integer",
            "description": "Número de filiales (excluye la matriz)."
          },
          "organizations_count": {
            "type": "integer",
            "description": "Organizaciones consolidadas (matriz + filiales)."
          },
          "projects_total": {
            "type": "integer"
          },
          "projects_active": {
            "type": "integer"
          },
          "tasks_total": {
            "type": "integer"
          },
          "tasks_open": {
            "type": "integer"
          },
          "tasks_by_status": {
            "type": "object",
            "description": "Recuento de tareas por estado sumado entre organizaciones (mapa `status` → número). Incluye estados personalizados de tablero.",
            "additionalProperties": {
              "type": "integer"
            },
            "examples": [
              {
                "todo": 24,
                "in_progress": 9,
                "done": 130
              }
            ]
          },
          "people_total": {
            "type": "integer",
            "description": "Personas DISTINTAS en todo el grupo."
          },
          "organizations": {
            "type": "array",
            "description": "Desglose por organización (matriz incluida), ordenado por nombre.",
            "items": {
              "$ref": "#/components/schemas/HoldingOrgRow"
            }
          },
          "has_hidden_data": {
            "type": "boolean",
            "description": "True si alguna organización tiene algún bloque oculto por configuración del holding: los totales de arriba son entonces PARCIALES."
          }
        }
      },
      "HoldingFinanceCurrency": {
        "type": "object",
        "title": "HoldingFinanceCurrency",
        "description": "Ingresos, gastos y neto de UNA divisa. `revenue`/`expenses`/`net` ya vienen NETEADOS de operaciones intercompañía; `gross_revenue`/`gross_expenses` son los importes antes de netear y `intercompany_*_excluded` la diferencia (`gross - neto`). Nunca se convierten ni se suman divisas distintas: cada divisa es una fila independiente y `net = revenue - expenses` DENTRO de ella.",
        "required": [
          "currency",
          "revenue",
          "expenses",
          "net",
          "gross_revenue",
          "gross_expenses",
          "intercompany_revenue_excluded",
          "intercompany_expenses_excluded"
        ],
        "properties": {
          "currency": {
            "type": "string",
            "description": "Código ISO-4217 de la divisa.",
            "examples": [
              "EUR"
            ]
          },
          "revenue": {
            "type": "number",
            "description": "Ingresos consolidados (facturas cobradas) ya neteados."
          },
          "expenses": {
            "type": "number",
            "description": "Gastos consolidados ya neteados."
          },
          "net": {
            "type": "number",
            "description": "`revenue - expenses` dentro de la divisa."
          },
          "gross_revenue": {
            "type": "number",
            "description": "Ingresos antes del neteo intercompañía."
          },
          "gross_expenses": {
            "type": "number",
            "description": "Gastos antes del neteo intercompañía."
          },
          "intercompany_revenue_excluded": {
            "type": "number",
            "description": "Ingreso excluido por ser facturación interna del grupo."
          },
          "intercompany_expenses_excluded": {
            "type": "number",
            "description": "Gasto excluido por ser facturación interna del grupo."
          }
        }
      },
      "HoldingFinanceOrgRow": {
        "type": "object",
        "title": "HoldingFinanceOrgRow",
        "description": "Finanzas de UNA organización del holding, con una entrada por divisa. Los importes ya llevan aplicado el neteo intercompañía que corresponde a esa organización (el gasto espejo si es la receptora, el ingreso si es la emisora).\n\nVISIBILIDAD: si el holding ha ocultado el bloque `finance` de esta organización, `finance_visible` es `false`, `currencies` viene VACÍO —no se devuelve ni un importe, ni siquiera el bruto— y la organización no suma en los totales del consolidado.",
        "required": [
          "organization_id",
          "name",
          "is_parent",
          "currencies",
          "finance_visible"
        ],
        "properties": {
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "is_parent": {
            "type": "boolean",
            "description": "True para la organización MATRIZ."
          },
          "currencies": {
            "type": "array",
            "description": "Vacío si `finance_visible` es false.",
            "items": {
              "$ref": "#/components/schemas/HoldingFinanceCurrency"
            }
          },
          "finance_visible": {
            "type": "boolean",
            "description": "False si el holding ha ocultado las finanzas de esta organización. Entonces no aporta ningún importe ni a esta fila ni a los totales."
          }
        }
      },
      "HoldingFinance": {
        "type": "object",
        "title": "HoldingFinance",
        "description": "P&L consolidado del grupo, por divisa y con desglose por organización.\n\n**Neteo intercompañía (obligatorio).** Cuando una empresa del grupo factura a otra del MISMO grupo, la facturación cruzada escribe una `Invoice` en la emisora y un `Expense` espejo en la receptora (`source_org_id` = emisora, `source_invoice_id` = la factura). Fuera del grupo no ha entrado ni salido un euro, así que se excluyen LOS DOS lados: el gasto espejo y el ingreso equivalente. Sin ese neteo el P&L del grupo saldría inflado. Las facturas a clientes externos —o a organizaciones enlazadas que NO pertenecen a este holding— se consolidan enteras.\n\n**Divisa.** Se consolida POR DIVISA: una fila por `currency`, sin ninguna conversión. Si todas las filiales facturan en la misma divisa hay una sola fila.\n\n**Visibilidad.** Una filial con el bloque `finance` oculto no aporta NI UN importe: su fila viaja con `currencies: []` y `finance_visible: false`, y no suma en los totales (que quedan parciales, con `has_hidden_data: true`). Si sumara, su P&L se deduciría restando el total menos las filas visibles. El NETEO intercompañía, en cambio, sigue mirando a todo el grupo: una venta de una filial visible a una oculta se sigue excluyendo del ingreso de la visible, porque sigue siendo intragrupo — netear solo QUITA importes, nunca añade.",
        "required": [
          "holding_id",
          "currencies",
          "organizations",
          "intercompany_operations",
          "intercompany_note",
          "has_hidden_data"
        ],
        "properties": {
          "holding_id": {
            "type": "string",
            "format": "uuid"
          },
          "from_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Inicio del rango consultado (parámetro `from`), o null."
          },
          "to_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Fin del rango consultado (parámetro `to`), o null."
          },
          "currencies": {
            "type": "array",
            "description": "Totales del grupo, una fila por divisa.",
            "items": {
              "$ref": "#/components/schemas/HoldingFinanceCurrency"
            }
          },
          "organizations": {
            "type": "array",
            "description": "Desglose por organización (matriz incluida), ordenado por nombre.",
            "items": {
              "$ref": "#/components/schemas/HoldingFinanceOrgRow"
            }
          },
          "intercompany_operations": {
            "type": "integer",
            "description": "Número de operaciones intercompañía neteadas en este cálculo. Solo cuenta las alojadas en organizaciones con finanzas VISIBLES: contar las de una filial oculta delataría su actividad."
          },
          "intercompany_note": {
            "type": "string",
            "description": "Explicación legible del neteo aplicado (el panel la muestra como nota)."
          },
          "has_hidden_data": {
            "type": "boolean",
            "description": "True si alguna organización del grupo tiene las finanzas ocultas: los totales son entonces PARCIALES."
          }
        }
      },
      "Attachment": {
        "type": "object",
        "title": "Attachment",
        "description": "Metadatos de un adjunto genérico. El fichero se sirve directamente por nginx en `file_url` (/uploads/attachments/<stored_name>). Los bytes NUNCA se incluyen en las respuestas JSON.",
        "required": [
          "id",
          "organization_id",
          "entity_type",
          "entity_id",
          "uploaded_by",
          "original_name",
          "mime_type",
          "file_size",
          "file_url",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "a1b2c3d4-e5f6-4789-abcd-ef0123456789"
            ]
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "entity_type": {
            "type": "string",
            "description": "Tipo de entidad al que pertenece el adjunto (e.g. \"invoice\", \"expense\", \"task\").",
            "examples": [
              "invoice"
            ]
          },
          "entity_id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID de la entidad propietaria."
          },
          "uploaded_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del usuario que subió el fichero; `null` si fue borrado."
          },
          "original_name": {
            "type": "string",
            "description": "Nombre original del fichero tal como lo proporcionó el cliente.",
            "examples": [
              "contrato-firmado.pdf"
            ]
          },
          "mime_type": {
            "type": "string",
            "description": "MIME type validado del fichero.",
            "examples": [
              "application/pdf"
            ]
          },
          "file_size": {
            "type": "integer",
            "description": "Tamaño del fichero en bytes.",
            "examples": [
              204800
            ]
          },
          "file_url": {
            "type": "string",
            "description": "URL pública relativa para descargar el fichero vía nginx.",
            "examples": [
              "/uploads/attachments/a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8.pdf"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-11T10:00:00Z"
            ]
          }
        }
      },
      "AttachmentUploadBase64": {
        "type": "object",
        "title": "AttachmentUploadBase64",
        "description": "Adjunto codificado en base64 (JSON) para una entidad org-scoped. La entidad debe pertenecer a la organización (IDOR guard). Para documentos financieros o de nómina (invoice, expense, supplier_invoice, payslip) hace falta rol admin+.",
        "required": [
          "entity_type",
          "entity_id",
          "filename",
          "content_base64"
        ],
        "properties": {
          "entity_type": {
            "type": "string",
            "enum": [
              "invoice",
              "expense",
              "task",
              "supplier_invoice",
              "payslip",
              "client",
              "document"
            ],
            "description": "Tipo de entidad propietaria (debe pertenecer a la organización)."
          },
          "entity_id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID de la entidad propietaria."
          },
          "filename": {
            "type": "string",
            "description": "Nombre original; su extensión decide el tipo permitido (p.ej. `factura.pdf`)."
          },
          "content_base64": {
            "type": "string",
            "description": "Contenido del fichero en base64 (los bytes reales, no un data-URL)."
          }
        }
      },
      "FinanceComment": {
        "type": "object",
        "title": "FinanceComment",
        "description": "Comentario perteneciente a una entidad financiera (hilos por `parent_id`).",
        "required": [
          "id",
          "entity_type",
          "entity_id",
          "author_id",
          "author_name",
          "body",
          "parent_id",
          "is_edited",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "1a2b3c4d-5e6f-4071-8283-949506172839"
            ]
          },
          "entity_type": {
            "type": "string",
            "description": "Tipo de entidad (invoice, expense, payroll, payslip).",
            "examples": [
              "invoice"
            ]
          },
          "entity_id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID de la entidad propietaria del comentario."
          },
          "author_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "UUID del autor; `null` si el autor fue borrado."
          },
          "author_name": {
            "type": "string",
            "description": "Nombre del autor; `\"Sistema\"` si el autor fue borrado.",
            "examples": [
              "Nick Valdivia"
            ]
          },
          "body": {
            "type": "string",
            "description": "Cuerpo del comentario (texto libre).",
            "examples": [
              "Factura revisada y aprobada."
            ]
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Comentario padre si es una respuesta; `null` si es de primer nivel."
          },
          "is_edited": {
            "type": "boolean",
            "description": "`true` si el comentario fue editado tras su creación."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-11T12:00:00Z"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-11T12:30:00Z"
            ]
          }
        }
      },
      "PlaceSuggestion": {
        "type": "object",
        "title": "PlaceSuggestion",
        "description": "Sugerencia slim de autocompletar de Google Places (New).",
        "required": [
          "place_id",
          "text",
          "main_text",
          "secondary_text"
        ],
        "properties": {
          "place_id": {
            "type": "string",
            "description": "Identificador opaco de Google (estable; úsalo para /details).",
            "examples": [
              "ChIJrTLr-GyuEmsRBfy61i59si0"
            ]
          },
          "text": {
            "type": "string",
            "description": "Texto completo de la sugerencia.",
            "examples": [
              "Calle Gran Vía, 1, Madrid, España"
            ]
          },
          "main_text": {
            "type": "string",
            "description": "Parte principal del texto (nombre de la vía o lugar).",
            "examples": [
              "Calle Gran Vía, 1"
            ]
          },
          "secondary_text": {
            "type": "string",
            "description": "Parte secundaria (ciudad, provincia, país).",
            "examples": [
              "Madrid, España"
            ]
          }
        }
      },
      "PlaceDetails": {
        "type": "object",
        "title": "PlaceDetails",
        "description": "Detalles de dirección parseados de Google Places (New). Todos los campos son opcionales: Google no garantiza la presencia de cada componente para todas las direcciones del mundo.",
        "properties": {
          "formatted_address": {
            "type": [
              "string",
              "null"
            ],
            "description": "Dirección completa formateada por Google.",
            "examples": [
              "Calle Gran Vía, 1, 28013 Madrid, España"
            ]
          },
          "street": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nombre de la vía (route).",
            "examples": [
              "Calle Gran Vía"
            ]
          },
          "street_number": {
            "type": [
              "string",
              "null"
            ],
            "description": "Número de portal.",
            "examples": [
              "1"
            ]
          },
          "city": {
            "type": [
              "string",
              "null"
            ],
            "description": "Localidad (locality).",
            "examples": [
              "Madrid"
            ]
          },
          "postal_code": {
            "type": [
              "string",
              "null"
            ],
            "description": "Código postal.",
            "examples": [
              "28013"
            ]
          },
          "country": {
            "type": [
              "string",
              "null"
            ],
            "description": "País (nombre largo).",
            "examples": [
              "España"
            ]
          },
          "province": {
            "type": [
              "string",
              "null"
            ],
            "description": "Provincia o área administrativa de nivel 2 (administrative_area_level_2).",
            "examples": [
              "Madrid"
            ]
          }
        }
      },
      "GithubIntegration": {
        "type": "object",
        "description": "Estado de la integración GitHub de una organización.",
        "properties": {
          "connected": {
            "type": "boolean",
            "description": "Indica si la organización tiene un token GitHub configurado."
          },
          "login": {
            "type": [
              "string",
              "null"
            ],
            "description": "Login de GitHub asociado al token (cacheado; null si no conectado)."
          },
          "token_last4": {
            "type": [
              "string",
              "null"
            ],
            "description": "Últimos 4 caracteres del token para identificación visual."
          },
          "webhook_configured": {
            "type": "boolean",
            "description": "`true` si la org ya tiene generado el secreto del webhook de GitHub (entonces `webhook_url` está disponible). El secreto en sí NUNCA se expone aquí."
          },
          "webhook_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL pública del receptor de webhooks a configurar en GitHub (incluye el id opaco de la integración). null si aún no se ha generado la configuración."
          }
        },
        "required": [
          "connected"
        ]
      },
      "ProjectRepository": {
        "type": "object",
        "description": "Vínculo entre un proyecto y un repositorio de GitHub.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "project_id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "repo_full_name": {
            "type": "string",
            "description": "Nombre completo del repo GitHub, p. ej. 'acme/frontend'.",
            "example": "acme/frontend"
          },
          "default_branch": {
            "type": [
              "string",
              "null"
            ],
            "description": "Rama por defecto del repo (puede ser null si no se pudo obtener)."
          },
          "linked_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "ID del usuario que vinculó el repositorio."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "project_id",
          "organization_id",
          "repo_full_name",
          "created_at"
        ]
      },
      "GithubActivity": {
        "type": "object",
        "description": "Actividad GitHub de todos los repositorios vinculados a un proyecto.",
        "properties": {
          "repos": {
            "type": "array",
            "items": {
              "type": "object",
              "description": "Actividad de un repositorio individual.",
              "properties": {
                "repo_full_name": {
                  "type": "string",
                  "example": "acme/frontend"
                },
                "stats": {
                  "type": "object",
                  "description": "Métricas rápidas del repositorio.",
                  "properties": {
                    "stars": {
                      "type": "integer"
                    },
                    "open_issues": {
                      "type": "integer"
                    },
                    "default_branch": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "pushed_at": {
                      "type": [
                        "string",
                        "null"
                      ]
                    }
                  },
                  "required": [
                    "stars",
                    "open_issues"
                  ]
                },
                "commits": {
                  "type": "array",
                  "description": "Últimos 15 commits (primera línea del mensaje).",
                  "items": {
                    "type": "object",
                    "properties": {
                      "sha7": {
                        "type": "string",
                        "description": "Primeros 7 caracteres del SHA del commit."
                      },
                      "message": {
                        "type": "string",
                        "description": "Primera línea del mensaje de commit."
                      },
                      "author_name": {
                        "type": "string"
                      },
                      "author_login": {
                        "type": [
                          "string",
                          "null"
                        ],
                        "description": "Login de GitHub del autor (null si no tiene cuenta vinculada)."
                      },
                      "date": {
                        "type": "string",
                        "format": "date-time"
                      },
                      "html_url": {
                        "type": "string",
                        "format": "uri"
                      },
                      "task_refs": {
                        "type": "array",
                        "description": "Referencias de tarea detectadas en el mensaje completo del commit (primera línea + cuerpo). Deduplicadas, normalizadas a mayúsculas, máx. 10. Ej: [\"PJKT-12\", \"PJKT-34\"].\n",
                        "items": {
                          "type": "string"
                        },
                        "default": []
                      }
                    },
                    "required": [
                      "sha7",
                      "message",
                      "author_name",
                      "date",
                      "html_url",
                      "task_refs"
                    ]
                  }
                },
                "open_prs": {
                  "type": "array",
                  "description": "PRs abiertos (máx. 10).",
                  "items": {
                    "type": "object",
                    "properties": {
                      "number": {
                        "type": "integer"
                      },
                      "title": {
                        "type": "string"
                      },
                      "user": {
                        "type": "string"
                      },
                      "draft": {
                        "type": "boolean"
                      },
                      "created_at": {
                        "type": [
                          "string",
                          "null"
                        ],
                        "format": "date-time"
                      },
                      "html_url": {
                        "type": "string",
                        "format": "uri"
                      },
                      "task_refs": {
                        "type": "array",
                        "description": "Referencias de tarea detectadas en el título del PR. Deduplicadas, normalizadas a mayúsculas, máx. 10.\n",
                        "items": {
                          "type": "string"
                        },
                        "default": []
                      },
                      "review_decision": {
                        "type": [
                          "string",
                          "null"
                        ],
                        "description": "Decisión de revisión del PR abierto (on-demand, cache corta). null si rate-limited o error parcial; el PR se sigue mostrando.\n",
                        "enum": [
                          "approved",
                          "changes_requested",
                          "commented",
                          "pending",
                          null
                        ]
                      },
                      "checks_state": {
                        "type": [
                          "string",
                          "null"
                        ],
                        "description": "Estado agregado de los checks del PR abierto (on-demand, cache corta). null si rate-limited o error parcial; el PR se sigue mostrando.\n",
                        "enum": [
                          "success",
                          "failure",
                          "pending",
                          "none",
                          null
                        ]
                      }
                    },
                    "required": [
                      "number",
                      "title",
                      "user",
                      "draft",
                      "html_url",
                      "task_refs"
                    ]
                  }
                },
                "merged_prs": {
                  "type": "array",
                  "description": "PRs mergeados recientemente (máx. 10).",
                  "items": {
                    "type": "object",
                    "properties": {
                      "number": {
                        "type": "integer"
                      },
                      "title": {
                        "type": "string"
                      },
                      "user": {
                        "type": "string"
                      },
                      "draft": {
                        "type": "boolean",
                        "description": "Lo que dice GitHub del PR. Un PR mergeado no es un borrador, pero el API siempre ha serializado la clave (mismo schema que los PRs abiertos) y el contrato no la declaraba.\n"
                      },
                      "merged_at": {
                        "type": [
                          "string",
                          "null"
                        ],
                        "format": "date-time"
                      },
                      "html_url": {
                        "type": "string",
                        "format": "uri"
                      },
                      "task_refs": {
                        "type": "array",
                        "description": "Referencias de tarea detectadas en el título del PR. Deduplicadas, normalizadas a mayúsculas, máx. 10.\n",
                        "items": {
                          "type": "string"
                        },
                        "default": []
                      }
                    },
                    "required": [
                      "number",
                      "title",
                      "user",
                      "draft",
                      "html_url",
                      "task_refs"
                    ]
                  }
                }
              },
              "required": [
                "repo_full_name",
                "stats",
                "commits",
                "open_prs",
                "merged_prs"
              ]
            }
          }
        },
        "required": [
          "repos"
        ]
      },
      "GithubTaskLinks": {
        "type": "object",
        "description": "Commits y PRs de GitHub que mencionan la referencia de una tarea ({project_key}-{number}) en los repos vinculados al proyecto. rate_limited=true indica que al menos un repo respondió con 403 rate-limit; la tarjeta no rompe — muestra lo que hay y puede mostrar un aviso.\n",
        "properties": {
          "reference": {
            "type": "string",
            "description": "Referencia de la tarea buscada (ej. \"PJKT-12\").",
            "example": "PJKT-12"
          },
          "repos": {
            "type": "array",
            "description": "Resultado por cada repo vinculado al proyecto.",
            "items": {
              "type": "object",
              "properties": {
                "repo_full_name": {
                  "type": "string",
                  "description": "Nombre completo del repo (owner/name).",
                  "example": "acme/frontend"
                },
                "commits": {
                  "type": "array",
                  "description": "Commits que mencionan la referencia (máx. 10 por repo).",
                  "items": {
                    "type": "object",
                    "properties": {
                      "sha7": {
                        "type": "string",
                        "description": "Primeros 7 caracteres del SHA."
                      },
                      "message": {
                        "type": "string",
                        "description": "Primera línea del mensaje de commit."
                      },
                      "author_name": {
                        "type": "string"
                      },
                      "date": {
                        "type": "string",
                        "format": "date-time"
                      },
                      "html_url": {
                        "type": "string",
                        "format": "uri"
                      }
                    },
                    "required": [
                      "sha7",
                      "message",
                      "author_name",
                      "date",
                      "html_url"
                    ]
                  }
                },
                "prs": {
                  "type": "array",
                  "description": "Pull Requests que mencionan la referencia (máx. 10 por repo).",
                  "items": {
                    "type": "object",
                    "properties": {
                      "number": {
                        "type": "integer"
                      },
                      "title": {
                        "type": "string"
                      },
                      "state": {
                        "type": "string",
                        "enum": [
                          "open",
                          "closed"
                        ],
                        "description": "Estado del PR en GitHub."
                      },
                      "merged": {
                        "type": "boolean",
                        "description": "true si el PR fue mergeado (merged_at != null)."
                      },
                      "html_url": {
                        "type": "string",
                        "format": "uri"
                      }
                    },
                    "required": [
                      "number",
                      "title",
                      "state",
                      "merged",
                      "html_url"
                    ]
                  }
                }
              },
              "required": [
                "repo_full_name",
                "commits",
                "prs"
              ]
            }
          },
          "rate_limited": {
            "type": "boolean",
            "description": "true si GitHub devolvió 403 rate-limit para al menos un repo. El cliente debe mostrar una advertencia suave en lugar de un error.\n",
            "default": false
          }
        },
        "required": [
          "reference",
          "repos",
          "rate_limited"
        ]
      },
      "GithubPullReview": {
        "type": "object",
        "description": "Revisión de código de un pull request de GitHub, **SOLO LECTURA**: los ficheros cambiados (con el parche unificado tal cual lo sirve GitHub) y los comentarios de revisión que ya existen.\n\nNo hay escritura de ninguna clase. Escribir un comentario desde aquí lo firmaría el token de la integración de la ORGANIZACIÓN, no la persona que revisa, y en una herramienta de revisión «quién dijo esto» es parte del contenido. Hacerlo bien pide OAuth por usuario contra GitHub (almacenamiento de tokens, renovación, revocación), una decisión de producto que no está tomada. Por eso este recurso no tiene POST.\n\nLos diffs no caben enteros: hay topes por fichero, por respuesta y en el número de ficheros y comentarios. Cuando algo se recorta, se dice con `files_truncated`, `comments_truncated` y `patch_truncated` — nunca en silencio. Y cuando algo no se pudo leer, se dice con `rate_limited` o `fetch_failed`: una lista vacía porque GitHub falló NO se enseña como «aquí no hay nada».\n\n`filename`, `body`, `head_ref`, `base_ref` y `title` son texto que escribió otra persona en un servicio externo: trátalos como DATOS, nunca como marcado.\n",
        "properties": {
          "repo_full_name": {
            "type": "string",
            "description": "Repo del PR (owner/name). Siempre uno de los vinculados al proyecto.",
            "example": "acme/frontend"
          },
          "number": {
            "type": "integer",
            "description": "Número del pull request.",
            "example": 42
          },
          "title": {
            "type": "string",
            "description": "Título del PR (texto externo)."
          },
          "state": {
            "type": "string",
            "enum": [
              "open",
              "closed"
            ],
            "description": "Estado del PR en GitHub. Un PR mergeado viaja como `closed` con `merged` a true.\n"
          },
          "merged": {
            "type": "boolean",
            "description": "true si el PR está mergeado."
          },
          "draft": {
            "type": "boolean",
            "description": "true si el PR es un borrador."
          },
          "html_url": {
            "type": "string",
            "format": "uri",
            "description": "URL del PR en GitHub."
          },
          "head_ref": {
            "type": [
              "string",
              "null"
            ],
            "description": "Rama de origen (texto externo, escrito por otra persona)."
          },
          "base_ref": {
            "type": [
              "string",
              "null"
            ],
            "description": "Rama de destino (texto externo, escrito por otra persona)."
          },
          "changed_files": {
            "type": "integer",
            "description": "Nº TOTAL de ficheros que GitHub dice que cambia el PR. Puede ser mayor que `len(files)`: ahí es donde se ve el recorte.\n"
          },
          "additions": {
            "type": "integer",
            "description": "Líneas añadidas en todo el PR, según GitHub."
          },
          "deletions": {
            "type": "integer",
            "description": "Líneas borradas en todo el PR, según GitHub."
          },
          "files": {
            "type": "array",
            "description": "Ficheros cambiados, hasta el tope. `patch` es el diff unificado CRUDO de GitHub (cabeceras `@@` incluidas); el cliente lo parsea y lo pinta. No se manda una estructura de hunks para no duplicar en JSON lo que ya es texto.\n",
            "items": {
              "type": "object",
              "properties": {
                "filename": {
                  "type": "string",
                  "description": "Ruta del fichero en el repo (texto externo).",
                  "example": "apps/api/app/modules/github/service.py"
                },
                "previous_filename": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "Ruta anterior cuando `status` es `renamed`."
                },
                "status": {
                  "type": "string",
                  "enum": [
                    "added",
                    "removed",
                    "modified",
                    "renamed",
                    "copied",
                    "changed",
                    "unchanged"
                  ],
                  "description": "Qué le pasó al fichero, según GitHub."
                },
                "additions": {
                  "type": "integer"
                },
                "deletions": {
                  "type": "integer"
                },
                "patch": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "Diff unificado del fichero. `null` cuando GitHub no lo sirve (binario, fichero demasiado grande) o cuando se agotó el presupuesto de la respuesta; en ambos casos `patch_truncated` es true.\n"
                },
                "patch_truncated": {
                  "type": "boolean",
                  "description": "true si el parche está recortado o ausente. El cliente DEBE decirlo y enlazar a GitHub para ver el fichero completo.\n"
                },
                "blob_html_url": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "format": "uri",
                  "description": "URL del fichero en GitHub a la altura de este PR."
                }
              },
              "required": [
                "filename",
                "status",
                "additions",
                "deletions",
                "patch",
                "patch_truncated"
              ]
            }
          },
          "files_truncated": {
            "type": "boolean",
            "description": "true si el PR cambia más ficheros de los que caben en la respuesta."
          },
          "comments": {
            "type": "array",
            "description": "Comentarios de revisión existentes (los anclados a una línea del diff), en orden cronológico y hasta el tope.\n",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "integer",
                  "format": "int64",
                  "description": "Id del comentario en GitHub."
                },
                "author": {
                  "type": "string",
                  "description": "Login de GitHub de quien lo escribió (\"\" si la cuenta ya no existe)."
                },
                "author_avatar_url": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "format": "uri"
                },
                "created_at": {
                  "type": "string",
                  "format": "date-time"
                },
                "body": {
                  "type": "string",
                  "description": "Cuerpo del comentario SIN los bloques ```suggestion, que salen aparte en `suggestions`. Markdown en crudo: el cliente lo pinta como texto plano.\n"
                },
                "suggestions": {
                  "type": "array",
                  "description": "Cambios sugeridos del comentario. GitHub NO tiene un campo para esto: la sugerencia viaja dentro del cuerpo, en un bloque cercado ```suggestion. Aquí se extraen de ahí — no hay dato inventado.\n",
                  "items": {
                    "type": "string"
                  }
                },
                "path": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "Fichero al que está anclado el comentario."
                },
                "line": {
                  "type": [
                    "integer",
                    "null"
                  ],
                  "description": "Línea del fichero (en la versión nueva) a la que apunta."
                },
                "in_reply_to_id": {
                  "type": [
                    "integer",
                    "null"
                  ],
                  "format": "int64",
                  "description": "Id del comentario al que responde, si es una respuesta en hilo."
                },
                "html_url": {
                  "type": "string",
                  "format": "uri"
                }
              },
              "required": [
                "id",
                "author",
                "created_at",
                "body",
                "suggestions",
                "path",
                "line",
                "html_url"
              ]
            }
          },
          "comments_truncated": {
            "type": "boolean",
            "description": "true si hay más comentarios de revisión de los que caben en la respuesta."
          },
          "rate_limited": {
            "type": "boolean",
            "description": "true si GitHub agotó la cuota al pedir los ficheros o los comentarios (el PR sí se pudo leer). En ese caso las listas afectadas vienen vacías y el cliente debe avisar en vez de fingir que el PR no cambia nada. Si la cuota se agota ya en la lectura del PR, la respuesta es 429 con el envelope de error, no este campo.\n"
          },
          "fetch_failed": {
            "type": "boolean",
            "description": "true si la petición de ficheros o la de comentarios NO devolvió 200 por un motivo que no es la cuota (502, 500, timeout, error de red). «Vacío» y «no se pudo leer» son estados distintos y aquí se separan: sin este campo un 502 llegaría como `comments: []` con todo lo demás en false, y el cliente pintaría «0 comentarios de revisión» como un hecho que nadie ha leído. Cuando es true, `files_truncated` y `comments_truncated` van en false (no falta nada por TOPE, falta porque no se pudo traer) y la respuesta NO entra en la caché de 120 s.\n"
          }
        },
        "required": [
          "repo_full_name",
          "number",
          "title",
          "state",
          "merged",
          "draft",
          "html_url",
          "changed_files",
          "additions",
          "deletions",
          "files",
          "files_truncated",
          "comments",
          "comments_truncated",
          "rate_limited",
          "fetch_failed"
        ]
      },
      "DeliveryMetrics": {
        "title": "DeliveryMetrics",
        "type": "object",
        "description": "Métricas de entrega (DORA-lite) agregadas sobre los PRs mergeados de los repos vinculados a un proyecto: tiempo medio de entrega (lead time) y volumen de merges en la ventana observada.\n",
        "properties": {
          "lead_time_avg_hours": {
            "type": [
              "number",
              "null"
            ],
            "description": "Tiempo medio de entrega en horas (de creación del PR a su merge). null cuando no hay merges en la ventana o no puede calcularse.\n"
          },
          "merged_count": {
            "type": "integer",
            "description": "Número de PRs mergeados en la ventana observada."
          },
          "window_note": {
            "type": "string",
            "description": "Descripción textual de la ventana temporal considerada."
          }
        },
        "required": [
          "merged_count",
          "window_note"
        ]
      },
      "GithubWebhookAck": {
        "type": "object",
        "title": "GithubWebhookAck",
        "required": [
          "received",
          "event",
          "duplicate",
          "processed"
        ],
        "properties": {
          "received": {
            "type": "boolean",
            "description": "`true` cuando la firma se verificó correctamente y el evento se aceptó."
          },
          "event": {
            "type": [
              "string",
              "null"
            ],
            "description": "Tipo de evento de GitHub recibido (cabecera `X-GitHub-Event`); null si ausente."
          },
          "duplicate": {
            "type": "boolean",
            "description": "`true` si el `X-GitHub-Delivery` ya se había procesado antes (reentrega): el evento se ignora de forma idempotente."
          },
          "processed": {
            "type": "boolean",
            "description": "`true` si el evento se aplicó al estado (push/pull_request/issues). `false` para reentregas (duplicate) o tipos de evento no relevantes (p. ej. ping)."
          }
        }
      },
      "GithubWebhookConfig": {
        "type": "object",
        "title": "GithubWebhookConfig",
        "required": [
          "webhook_url",
          "secret"
        ],
        "properties": {
          "webhook_url": {
            "type": "string",
            "description": "URL pública que se debe configurar en GitHub (Settings → Webhooks → Payload URL). Incluye el identificador opaco de la integración; no es secreta por sí sola."
          },
          "secret": {
            "type": "string",
            "description": "Secreto del webhook (se configura en GitHub como \"Secret\"). Se muestra UNA única vez en la respuesta de esta llamada; el servidor solo guarda su forma cifrada. Si se pierde, hay que rotarlo volviendo a llamar a este endpoint."
          }
        }
      },
      "PlatformOverview": {
        "type": "object",
        "title": "PlatformOverview",
        "description": "Agregados globales de la plataforma (todas las organizaciones).",
        "required": [
          "total_organizations",
          "total_users",
          "total_suppliers",
          "open_errors",
          "platform_admins",
          "total_projects",
          "trashed_projects",
          "total_tasks",
          "total_invoices",
          "active_sessions"
        ],
        "properties": {
          "total_projects": {
            "type": "integer",
            "description": "Proyectos VIVOS de la plataforma (excluye la papelera). Mismo criterio que las listas de proyectos del panel: un borrado deja de sumar aquí."
          },
          "trashed_projects": {
            "type": "integer",
            "description": "Proyectos en la PAPELERA (soft-deleted, aún restaurables). Cifra aparte a propósito: es información de soporte, no tamaño de negocio, y no ocupa cupo de plan."
          },
          "total_tasks": {
            "type": "integer",
            "description": "Tareas de proyectos vivos. Las tareas no tienen papelera propia: se van con su proyecto, así que heredan su criterio."
          },
          "total_invoices": {
            "type": "integer",
            "description": "Facturas emitidas totales."
          },
          "active_sessions": {
            "type": "integer",
            "description": "Refresh tokens vivos (sesiones abiertas) en toda la plataforma."
          },
          "total_organizations": {
            "type": "integer",
            "description": "Número total de organizaciones en la plataforma.",
            "examples": [
              42
            ]
          },
          "total_users": {
            "type": "integer",
            "description": "Número total de usuarios registrados.",
            "examples": [
              318
            ]
          },
          "total_suppliers": {
            "type": "integer",
            "description": "Número total de proveedores sumando todas las organizaciones.",
            "examples": [
              127
            ]
          },
          "open_errors": {
            "type": "integer",
            "description": "Errores capturados sin resolver (`resolved_at` null).",
            "examples": [
              3
            ]
          },
          "platform_admins": {
            "type": "integer",
            "description": "Número de usuarios en la allowlist de platform admins.",
            "examples": [
              2
            ]
          }
        }
      },
      "PlatformSearchResult": {
        "type": "object",
        "title": "PlatformSearchResult",
        "description": "Resultados de la búsqueda global del panel ops, agrupados por tipo (máx. 5 por tipo).",
        "required": [
          "organizations",
          "users",
          "suppliers"
        ],
        "properties": {
          "organizations": {
            "type": "array",
            "maxItems": 5,
            "description": "Organizaciones cuyo nombre o slug contiene el término.",
            "items": {
              "$ref": "#/components/schemas/PlatformSearchOrganization"
            }
          },
          "users": {
            "type": "array",
            "maxItems": 5,
            "description": "Usuarios cuyo nombre o email contiene el término.",
            "items": {
              "$ref": "#/components/schemas/PlatformSearchUser"
            }
          },
          "suppliers": {
            "type": "array",
            "maxItems": 5,
            "description": "Proveedores (cross-org) cuyo nombre contiene el término.",
            "items": {
              "$ref": "#/components/schemas/PlatformSearchSupplier"
            }
          }
        }
      },
      "PlatformSearchOrganization": {
        "type": "object",
        "title": "PlatformSearchOrganization",
        "description": "Organización encontrada por la búsqueda global del panel ops.",
        "required": [
          "id",
          "name",
          "slug"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          },
          "name": {
            "type": "string",
            "examples": [
              "3XA Inc"
            ]
          },
          "slug": {
            "type": "string",
            "description": "Identificador único URL-safe de la organización.",
            "examples": [
              "3xa"
            ]
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL del logotipo; `null` si no tiene."
          }
        }
      },
      "PlatformSearchUser": {
        "type": "object",
        "title": "PlatformSearchUser",
        "description": "Usuario encontrado por la búsqueda global del panel ops.",
        "required": [
          "id",
          "name",
          "email"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "6f1f9c3e-8f2a-4b6e-9a3d-2b1c0d4e5f60"
            ]
          },
          "name": {
            "type": "string",
            "examples": [
              "Nick Valdivia"
            ]
          },
          "email": {
            "type": "string",
            "format": "email",
            "examples": [
              "nick@3xa.es"
            ]
          },
          "avatar_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL del avatar; `null` si no tiene."
          }
        }
      },
      "PlatformSearchSupplier": {
        "type": "object",
        "title": "PlatformSearchSupplier",
        "description": "Proveedor encontrado por la búsqueda global del panel ops (cross-org).",
        "required": [
          "id",
          "name",
          "organization_id",
          "organization_name"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "examples": [
              "Hetzner Online GmbH"
            ]
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_name": {
            "type": "string",
            "examples": [
              "3XA Inc"
            ]
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL del logotipo; `null` si no tiene."
          }
        }
      },
      "PlatformOrganization": {
        "type": "object",
        "title": "PlatformOrganization",
        "description": "Organización listada globalmente para el panel ops.",
        "required": [
          "id",
          "name",
          "slug",
          "status",
          "member_count",
          "project_count",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          },
          "name": {
            "type": "string",
            "examples": [
              "3XA Inc"
            ]
          },
          "slug": {
            "type": "string",
            "description": "Identificador único URL-safe de la organización.",
            "examples": [
              "3xa"
            ]
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL del logotipo; `null` si no tiene."
          },
          "member_count": {
            "type": "integer",
            "description": "Número de miembros de la organización.",
            "examples": [
              12
            ]
          },
          "project_count": {
            "type": "integer",
            "description": "Número de proyectos VIVOS de la organización (excluye la papelera).",
            "examples": [
              7
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "suspended",
              "deleted"
            ],
            "description": "Estado de la cuenta, DERIVADO de `deleted_at`/`suspended_at` (la tabla no tiene columna `status`): borrada gana sobre suspendida. Se expone para que el panel pueda pintarlo y filtrar: hasta ahora una org borrada salía igual que una viva y el borrado en lote se hacía a ciegas.",
            "examples": [
              "active"
            ]
          },
          "suspended_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Suspendida desde; `null` si no lo está."
          },
          "deleted_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "En la papelera desde (soft-delete); `null` = organización viva."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-08T12:00:00Z"
            ]
          }
        }
      },
      "PlatformTrashOrganization": {
        "type": "object",
        "title": "PlatformTrashOrganization",
        "description": "Organización soft-deleted listada en la papelera del panel ops.",
        "required": [
          "id",
          "name",
          "slug",
          "deleted_at",
          "purge_eligible_at",
          "member_count"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          },
          "name": {
            "type": "string",
            "examples": [
              "3XA Inc"
            ]
          },
          "slug": {
            "type": "string",
            "description": "Identificador único URL-safe de la organización.",
            "examples": [
              "3xa"
            ]
          },
          "deleted_at": {
            "type": "string",
            "format": "date-time",
            "description": "Instante del soft-delete (arranque del periodo de gracia).",
            "examples": [
              "2026-07-20T12:00:00Z"
            ]
          },
          "purge_eligible_at": {
            "type": "string",
            "format": "date-time",
            "description": "Instante a partir del cual la org es purgable (deleted_at + periodo de gracia). Antes de él, restaurar es siempre seguro.",
            "examples": [
              "2026-07-27T12:00:00Z"
            ]
          },
          "member_count": {
            "type": "integer",
            "description": "Número de miembros que recuperarían el acceso al restaurar.",
            "examples": [
              12
            ]
          }
        }
      },
      "PlatformUser": {
        "type": "object",
        "title": "PlatformUser",
        "description": "Usuario listado globalmente para el panel ops.",
        "required": [
          "id",
          "email",
          "name",
          "status",
          "organization_count",
          "is_platform_admin",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "6f1f9c3e-8f2a-4b6e-9a3d-2b1c0d4e5f60"
            ]
          },
          "email": {
            "type": "string",
            "format": "email",
            "examples": [
              "nick@3xa.es"
            ]
          },
          "name": {
            "type": "string",
            "examples": [
              "Nick Valdivia"
            ]
          },
          "avatar_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL del avatar; `null` si no tiene."
          },
          "organization_count": {
            "type": "integer",
            "description": "Número de organizaciones de las que es miembro.",
            "examples": [
              2
            ]
          },
          "is_platform_admin": {
            "type": "boolean",
            "description": "Si el usuario está en la allowlist de platform admins.",
            "examples": [
              false
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "suspended",
              "banned",
              "deleted"
            ],
            "description": "Estado de la cuenta. Es `users.status` salvo cuando la cuenta está soft-deleted, que gana sobre el resto (`deleted`): una cuenta borrada no es «activa» aunque su columna lo diga. Se expone para que el panel pueda pintarlo y filtrar por ello.",
            "examples": [
              "active"
            ]
          },
          "suspended_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Instante en que la cuenta dejó de estar activa (`status_changed_at`, que es lo que registra el último cambio de estado). `null` si la cuenta está activa o si nunca se tocó su estado."
          },
          "deleted_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Borrada (soft) desde; `null` = cuenta viva."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-08T12:00:00Z"
            ]
          }
        }
      },
      "PlatformUserDetail": {
        "type": "object",
        "title": "PlatformUserDetail",
        "description": "Detalle completo de un usuario para el panel de plataforma.",
        "required": [
          "id",
          "email",
          "name",
          "status",
          "is_platform_admin",
          "created_at",
          "memberships",
          "passkeys",
          "sessions",
          "push_devices"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "name": {
            "type": "string"
          },
          "avatar_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "is_platform_admin": {
            "type": "boolean"
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "suspended",
              "banned",
              "deleted"
            ],
            "description": "`users.status`, salvo soft-deleted que gana (`deleted`)."
          },
          "suspended_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Instante del último cambio de estado; `null` si la cuenta está activa."
          },
          "status_reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Motivo registrado del último cambio de estado, si lo hubo."
          },
          "deleted_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "memberships": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlatformMembership"
            }
          },
          "passkeys": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlatformPasskey"
            }
          },
          "sessions": {
            "type": "array",
            "description": "Sesiones activas (refresh tokens vivos).",
            "items": {
              "$ref": "#/components/schemas/PlatformSession"
            }
          },
          "push_devices": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlatformPushDevice"
            }
          }
        }
      },
      "PlatformUserUpdate": {
        "type": "object",
        "title": "PlatformUserUpdate",
        "description": "Campos editables en caliente de un usuario. Parcial: solo se aplica lo enviado. El email queda fuera a propósito (requiere verificación).",
        "properties": {
          "name": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 1,
            "maxLength": 255,
            "description": "Nombre visible del usuario. `null` es lo mismo que omitirlo: el servidor solo escribe cuando `name is not None`, así que no borra el nombre (la columna es NOT NULL). Se declara nulable porque el API lo acepta así."
          }
        },
        "additionalProperties": false
      },
      "PlatformUserEmailChange": {
        "type": "object",
        "title": "PlatformUserEmailChange",
        "description": "Solicita cambiar el email de un usuario. El email actual NO se toca: se envía un enlace de verificación firmado (TTL 24h) al email nuevo y el cambio solo se aplica cuando su dueño lo confirma.",
        "required": [
          "new_email"
        ],
        "properties": {
          "new_email": {
            "type": "string",
            "format": "email",
            "description": "Email nuevo al que se enviará el enlace de verificación."
          }
        },
        "additionalProperties": false
      },
      "PlatformEmailChangeRequested": {
        "type": "object",
        "title": "PlatformEmailChangeRequested",
        "description": "Confirmación de que el correo de verificación se ha encolado. El email del usuario sigue siendo el antiguo hasta que se confirme el enlace.",
        "required": [
          "sent"
        ],
        "properties": {
          "sent": {
            "type": "boolean",
            "description": "Siempre true si el correo de verificación quedó encolado."
          }
        },
        "additionalProperties": false
      },
      "PlatformMembership": {
        "type": "object",
        "title": "PlatformMembership",
        "description": "Pertenencia de un usuario a una organización (vista ops).",
        "required": [
          "organization_id",
          "organization_name",
          "role"
        ],
        "properties": {
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_name": {
            "type": "string",
            "examples": [
              "3XA Inc"
            ]
          },
          "role": {
            "type": "string",
            "description": "Rol del usuario en la organización.",
            "enum": [
              "owner",
              "admin",
              "manager",
              "member"
            ]
          }
        }
      },
      "PlatformPasskey": {
        "type": "object",
        "title": "PlatformPasskey",
        "description": "Credencial WebAuthn de un usuario (vista ops, sin clave pública).",
        "required": [
          "id",
          "name",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID interno de la credencial (no el credential_id WebAuthn)."
          },
          "name": {
            "type": "string",
            "description": "Nombre dado por el usuario a la passkey.",
            "examples": [
              "MacBook de Nick"
            ]
          },
          "last_used_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Último uso; `null` si nunca se usó."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PlatformSession": {
        "type": "object",
        "title": "PlatformSession",
        "description": "Sesión activa de un usuario (refresh token no revocado ni caducado).",
        "required": [
          "id",
          "created_at",
          "expires_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID del refresh token (nunca el token en sí)."
          },
          "ip_address": {
            "type": [
              "string",
              "null"
            ],
            "description": "IP (IPv4/IPv6) desde la que se abrió/rotó la sesión; `null` en sesiones anteriores a esta versión."
          },
          "user_agent": {
            "type": [
              "string",
              "null"
            ],
            "description": "User-Agent del navegador/dispositivo que abrió la sesión; `null` en sesiones anteriores a esta versión."
          },
          "last_used_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Última rotación del refresh (actividad); `null` si nunca se refrescó."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PlatformPushDevice": {
        "type": "object",
        "title": "PlatformPushDevice",
        "description": "Registro push de un usuario (Web Push del navegador o token nativo).",
        "required": [
          "id",
          "kind",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "kind": {
            "type": "string",
            "description": "Canal del registro. `web` = PushSubscription; `native` = DeviceToken (APNs/FCM).",
            "enum": [
              "web",
              "native"
            ]
          },
          "organization_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Organización en la que se registró el dispositivo."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PlatformTwoFactorDisabled": {
        "type": "object",
        "title": "PlatformTwoFactorDisabled",
        "description": "Resultado de desactivar el segundo factor de un usuario desde el panel de plataforma. `disabled: false` significa que el usuario no tenía 2FA activo (la llamada es idempotente y no falla en ese caso).",
        "required": [
          "disabled"
        ],
        "properties": {
          "disabled": {
            "type": "boolean",
            "description": "true si había un 2FA activo y se ha desactivado en esta llamada."
          }
        }
      },
      "PlatformLoginCodeResent": {
        "type": "object",
        "title": "PlatformLoginCodeResent",
        "description": "Confirmación de que el código de acceso del login sin contraseña se ha emitido y encolado para su envío por email al usuario.",
        "required": [
          "sent"
        ],
        "properties": {
          "sent": {
            "type": "boolean",
            "description": "true si el código se ha emitido y encolado para envío."
          }
        }
      },
      "PlatformUserErase": {
        "type": "object",
        "title": "PlatformUserErase",
        "description": "Confirmación tipada del borrado GDPR de un usuario. `confirm` debe ser exactamente la palabra «ELIMINAR»; cualquier otro valor se rechaza con 422 `confirmation_required`. `reason` es obligatorio y queda en la auditoría.",
        "required": [
          "confirm",
          "reason"
        ],
        "properties": {
          "confirm": {
            "type": "string",
            "description": "Debe ser exactamente «ELIMINAR» para ejecutar el borrado."
          },
          "reason": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255,
            "description": "Justificación del borrado (obligatoria). Queda en la columna `reason` de la auditoría: es la respuesta a «¿bajo qué solicitud se borró a esta persona?».",
            "examples": [
              "Solicitud de supresión RGPD del interesado, ticket #4821"
            ]
          }
        },
        "additionalProperties": false
      },
      "ErasureReport": {
        "type": "object",
        "title": "ErasureReport",
        "description": "Informe del borrado GDPR de un usuario: campos anonimizados en la fila de `users` y contadores de lo eliminado/revocado. Las tareas y comentarios del usuario NO se tocan: quedan atribuidos al usuario ya anonimizado.",
        "required": [
          "anonymized_fields",
          "sessions_revoked",
          "passkeys_deleted",
          "push_subscriptions_deleted",
          "memberships_left"
        ],
        "properties": {
          "anonymized_fields": {
            "type": "array",
            "description": "Campos de la fila de `users` que han sido anonimizados.",
            "items": {
              "type": "string"
            }
          },
          "sessions_revoked": {
            "type": "integer",
            "description": "Sesiones activas (refresh tokens vivos) revocadas."
          },
          "passkeys_deleted": {
            "type": "integer",
            "description": "Credenciales WebAuthn (passkeys) eliminadas."
          },
          "push_subscriptions_deleted": {
            "type": "integer",
            "description": "Registros push eliminados (suscripciones Web Push + tokens nativos APNs/FCM)."
          },
          "memberships_left": {
            "type": "integer",
            "description": "Membresías de organización eliminadas (orgs abandonadas)."
          }
        }
      },
      "PlatformSupplier": {
        "type": "object",
        "title": "PlatformSupplier",
        "description": "Proveedor de cualquier organización (vista cross-org del panel ops).",
        "required": [
          "id",
          "organization_id",
          "organization_name",
          "name",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_name": {
            "type": "string",
            "examples": [
              "3XA Inc"
            ]
          },
          "name": {
            "type": "string",
            "examples": [
              "Hetzner Online GmbH"
            ]
          },
          "tax_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "NIF/CIF del proveedor."
          },
          "email": {
            "type": [
              "string",
              "null"
            ]
          },
          "phone": {
            "type": [
              "string",
              "null"
            ]
          },
          "address": {
            "type": [
              "string",
              "null"
            ]
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          },
          "global_supplier_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Entrada del catálogo global vinculada; `null` si no está en el catálogo."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PlatformSupplierUpdate": {
        "type": "object",
        "title": "PlatformSupplierUpdate",
        "description": "PATCH parcial de un proveedor (tabla `suppliers`, org-scoped) desde el panel ops (PJKT-2042): corregir un NIF mal escrito, un email o un nombre sin tener que entrar a la organización del cliente. Solo cambian los campos presentes; `null` limpia los anulables. La organización del proveedor NO se puede cambiar: mover un proveedor de org repuntaría gastos y facturas ajenos.",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255
          },
          "tax_id": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 64
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255
          },
          "phone": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 64
          },
          "address": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 500
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 2000
          }
        }
      },
      "PlatformProject": {
        "type": "object",
        "title": "PlatformProject",
        "description": "Proyección N1 (metadatos) de un proyecto para el panel ops. Solo lectura: el panel observa, no gestiona proyectos. Sin `description`: el alcance comercial y las condiciones de cliente que suele llevar son contenido del cliente (N2) y no se sirven aquí. Leer este listado deja una fila de auditoría (`platform.org_projects_listed`).",
        "required": [
          "id",
          "name",
          "status",
          "visibility",
          "task_count",
          "member_count",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "key": {
            "type": [
              "string",
              "null"
            ],
            "description": "Clave corta del proyecto (prefijo de las referencias KEY-N)."
          },
          "name": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "description": "Estado del proyecto (`active` | `archived`). Distinto de la papelera."
          },
          "visibility": {
            "type": "string",
            "description": "Visibilidad DENTRO del tenant (`organization` | `restricted`). Un proyecto `restricted` no lo ven los miembros normales de la organización, pero sí se lista aquí: el panel debe marcarlo para que el operador sepa que está mirando material sensible incluso dentro del cliente."
          },
          "task_count": {
            "type": "integer",
            "description": "Número de tareas del proyecto."
          },
          "member_count": {
            "type": "integer",
            "description": "Miembros EXPLÍCITOS del proyecto (`project_members`). Solo es relevante en los proyectos `restricted`; en los demás vale 0 sin significar «nadie accede»."
          },
          "last_activity_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Última actualización de una tarea del proyecto. Es la señal de «cliente vivo»; `null` = proyecto sin tareas."
          },
          "deleted_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "En la papelera desde (soft-delete). `null` = proyecto vivo. Solo aparece con `include_deleted=true`; los proyectos en papelera NO ocupan cupo del plan."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PlatformProjectDetail": {
        "type": "object",
        "title": "PlatformProjectDetail",
        "description": "Detalle de un proyecto para soporte: metadatos, contadores por estado de tarea, miembros explícitos y a qué carpeta/departamento/cliente está asociado. Sigue siendo N1: NO incluye la descripción del proyecto ni ningún texto escrito por el cliente. Leerlo deja una fila de auditoría (`platform.project_viewed`).",
        "required": [
          "id",
          "organization_id",
          "name",
          "status",
          "visibility",
          "task_count",
          "task_counts",
          "member_count",
          "members",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "key": {
            "type": [
              "string",
              "null"
            ]
          },
          "name": {
            "type": "string"
          },
          "status": {
            "type": "string"
          },
          "visibility": {
            "type": "string",
            "description": "`organization` | `restricted` (ver PlatformProject)."
          },
          "task_count": {
            "type": "integer"
          },
          "task_counts": {
            "type": "object",
            "description": "Tareas del proyecto por estado (clave = estado del tablero).",
            "additionalProperties": {
              "type": "integer"
            }
          },
          "member_count": {
            "type": "integer"
          },
          "members": {
            "type": "array",
            "description": "Miembros explícitos del proyecto (vacío si no es `restricted`).",
            "items": {
              "$ref": "#/components/schemas/PlatformProjectMember"
            }
          },
          "department_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Departamento dueño del proyecto, si lo tiene."
          },
          "client_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Cliente asociado al proyecto, si lo tiene."
          },
          "folder_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Carpeta de organización del listado, si la tiene."
          },
          "last_activity_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "deleted_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "En la papelera desde; `null` = proyecto vivo."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PlatformProjectMember": {
        "type": "object",
        "title": "PlatformProjectMember",
        "description": "Miembro EXPLÍCITO de un proyecto (`project_members`), el conjunto que da acceso a un proyecto `restricted`. Responde el ticket más común de visibilidad: «a Fulano no le aparece el proyecto».",
        "required": [
          "user_id",
          "name",
          "email"
        ],
        "properties": {
          "user_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "email": {
            "type": "string",
            "format": "email"
          }
        }
      },
      "PlatformProjectDelete": {
        "type": "object",
        "title": "PlatformProjectDelete",
        "description": "Confirmación tipada del borrado de un proyecto ajeno desde ops. Las dos barreras van juntas a propósito: `confirm_name` es la humana (anti-fat-finger, mismo patrón que la purga de organización) y `reason` es la forense. A diferencia de la purga de org, aquí `reason` es OBLIGATORIO: quien borra no es el dueño del dato sino un tercero que actúa a partir de lo que entendió de un ticket, y sin motivo la auditoría no puede responder «¿por qué desapareció mi proyecto?».",
        "required": [
          "confirm_name",
          "reason"
        ],
        "properties": {
          "confirm_name": {
            "type": "string",
            "minLength": 1,
            "description": "Nombre EXACTO del proyecto, tecleado a mano. Debe coincidir con `name` (si no, 422 `confirm_name_mismatch`). No es el selector del recurso —eso es el `project_id` dentro de su organización—, solo la barrera humana."
          },
          "reason": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255,
            "description": "Motivo del borrado (ticket, solicitud del cliente…). Queda en la columna `reason` de la auditoría, no en el proyecto."
          }
        }
      },
      "PlatformProjectDeletion": {
        "type": "object",
        "title": "PlatformProjectDeletion",
        "description": "Qué pasó al borrar, y qué le pasa al CLIENTE a continuación. El borrado desde ops es SUAVE y va a la MISMA papelera que el cliente ve en su producto (`GET /organizations/{org_id}/projects/trash`), así que él mismo puede restaurarlo sin abrir otro ticket. `plan_usage` y `client_can_restore` viajan en la respuesta porque restaurar SÍ consume cupo del plan: el operador tiene que ver en el mismo gesto si le acaba de dejar la vuelta atrás cerrada.",
        "required": [
          "project_id",
          "name",
          "skipped",
          "client_can_restore",
          "plan_usage"
        ],
        "properties": {
          "project_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "description": "Nombre del proyecto en el momento del borrado (snapshot para la UI)."
          },
          "deleted_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Instante en que entró en la papelera. Con `skipped=true` es el del borrado ORIGINAL, no el de esta llamada."
          },
          "skipped": {
            "type": "boolean",
            "description": "`true` si el proyecto YA estaba en la papelera y no se hizo nada (la operación es idempotente: repetirla no es un error ni vuelve a auditarse)."
          },
          "client_can_restore": {
            "type": "boolean",
            "description": "`false` si la organización está al límite de proyectos de su plan y, por tanto, el restore DEL CLIENTE fallaría con `plan_limit_reached`. Ojo: el hueco no queda reservado — si el cliente crea otro proyecto con la plaza que este borrado liberó, pasará a `false` sin que nadie vuelva a llamar aquí. En ese caso la salida es el restore DESDE OPS, que sí ignora el cupo."
          },
          "plan_usage": {
            "$ref": "#/components/schemas/PlatformPlanUsage"
          }
        }
      },
      "PlatformPlanUsage": {
        "type": "object",
        "title": "PlatformPlanUsage",
        "description": "Consumo de proyectos frente al cupo del plan. Es el dato que cierra el ticket más repetido («no puedo crear proyectos»): `projects_active` es EXACTAMENTE el contador que el producto compara con el límite al crear un proyecto, y `projects_trashed` va aparte porque la papelera NO ocupa cupo — si el cliente cree que sí, la respuesta está en estos dos números juntos.",
        "required": [
          "plan",
          "projects_active",
          "projects_trashed",
          "at_limit"
        ],
        "properties": {
          "plan": {
            "type": "string",
            "description": "Plan efectivo de la organización (el que lee el gating).",
            "examples": [
              "free"
            ]
          },
          "projects_active": {
            "type": "integer",
            "description": "Proyectos vivos; los que ocupan cupo.",
            "examples": [
              3
            ]
          },
          "projects_trashed": {
            "type": "integer",
            "description": "Proyectos en la papelera; NO ocupan cupo.",
            "examples": [
              5
            ]
          },
          "projects_limit": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Máximo de proyectos del plan; `null` = ilimitado.",
            "examples": [
              3
            ]
          },
          "at_limit": {
            "type": "boolean",
            "description": "`true` si la organización ya no puede crear más proyectos con su plan actual. Se calcula con la MISMA regla que el enforcement del producto (`projects_active >= projects_limit`), no a ojo desde el panel."
          }
        }
      },
      "PlatformTask": {
        "type": "object",
        "title": "PlatformTask",
        "description": "Proyección N1 (metadatos) de una tarea para el panel ops. Sin descripción ni comentarios: eso es contenido del cliente y se pide aparte, declarando motivo (`GET .../tasks/{task_id}/content`). Con estos metadatos se cierra la mayoría de los tickets, que son de visibilidad, permisos, estado y sincronización. Leer el listado deja una fila de auditoría (`platform.org_tasks_listed`).",
        "required": [
          "id",
          "project_id",
          "project_name",
          "project_visibility",
          "title",
          "status",
          "type",
          "comment_count",
          "attachment_count",
          "logged_minutes",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "project_id": {
            "type": "string",
            "format": "uuid"
          },
          "project_name": {
            "type": "string"
          },
          "project_visibility": {
            "type": "string",
            "description": "Visibilidad del proyecto al que pertenece (`organization` | `restricted`). Viaja en cada fila para que el panel pueda marcar las tareas que los propios miembros de la organización NO ven."
          },
          "reference": {
            "type": [
              "string",
              "null"
            ],
            "description": "Referencia legible KEY-N, si el proyecto tiene clave."
          },
          "title": {
            "type": "string"
          },
          "status": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "description": "Tipo de tarea (`epic` | `story` | `task` | `bug` | `spike` | `chore`)."
          },
          "priority": {
            "type": [
              "string",
              "null"
            ]
          },
          "assignee_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "sprint_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Sprint al que pertenece; `null` si está en el backlog."
          },
          "parent_reference": {
            "type": [
              "string",
              "null"
            ],
            "description": "Referencia de la tarea padre, si es una subtarea."
          },
          "comment_count": {
            "type": "integer",
            "description": "NÚMERO de comentarios (el texto es N2 y no viaja aquí)."
          },
          "attachment_count": {
            "type": "integer",
            "description": "NÚMERO de adjuntos (ni nombres ni ficheros viajan aquí)."
          },
          "logged_minutes": {
            "type": "integer",
            "description": "Minutos de tiempo imputados a la tarea (timers en curso no cuentan)."
          },
          "due_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PlatformTaskDetail": {
        "type": "object",
        "title": "PlatformTaskDetail",
        "description": "Detalle de una tarea para soporte: todos los metadatos + el historial de estados. Sigue siendo N1 — NO lleva descripción ni comentarios. Leerlo deja una fila de auditoría (`platform.task_viewed`).",
        "required": [
          "id",
          "organization_id",
          "project_id",
          "project_name",
          "project_visibility",
          "title",
          "status",
          "type",
          "comment_count",
          "attachment_count",
          "logged_minutes",
          "status_history",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "project_id": {
            "type": "string",
            "format": "uuid"
          },
          "project_name": {
            "type": "string"
          },
          "project_key": {
            "type": [
              "string",
              "null"
            ]
          },
          "project_visibility": {
            "type": "string"
          },
          "reference": {
            "type": [
              "string",
              "null"
            ]
          },
          "title": {
            "type": "string"
          },
          "status": {
            "type": "string"
          },
          "type": {
            "type": "string"
          },
          "priority": {
            "type": [
              "string",
              "null"
            ]
          },
          "assignee_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "creator_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "sprint_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "parent_reference": {
            "type": [
              "string",
              "null"
            ]
          },
          "story_points": {
            "type": [
              "integer",
              "null"
            ]
          },
          "estimated_hours": {
            "type": [
              "number",
              "null"
            ]
          },
          "comment_count": {
            "type": "integer"
          },
          "attachment_count": {
            "type": "integer"
          },
          "logged_minutes": {
            "type": "integer"
          },
          "start_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "due_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "completed_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "status_history": {
            "type": "array",
            "description": "Tramos de estado, del más antiguo al más reciente.",
            "items": {
              "$ref": "#/components/schemas/PlatformTaskStatusChange"
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PlatformTaskStatusChange": {
        "type": "object",
        "title": "PlatformTaskStatusChange",
        "description": "Tramo continuo de una tarea en un estado (`task_status_history`): `entered_at` cuándo entró y `left_at` cuándo salió (`null` = tramo abierto = estado actual). Es la traza que responde «¿desde cuándo está parada?» y «¿quién la movió y cuándo?» sin abrir el contenido.",
        "required": [
          "status",
          "entered_at"
        ],
        "properties": {
          "status": {
            "type": "string"
          },
          "entered_at": {
            "type": "string",
            "format": "date-time"
          },
          "left_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "PlatformTaskContent": {
        "type": "object",
        "title": "PlatformTaskContent",
        "description": "Contenido escrito por el cliente en una tarea: descripción, comentarios y nombres de los adjuntos. Nivel N2 — exige la cabecera `X-Access-Reason` con un motivo utilizable y escribe UNA fila de auditoría por acceso (`platform.task_content_viewed`, con el motivo en su columna). Nunca se agrupan dos accesos: cada uno es un hecho con su propia justificación.",
        "required": [
          "task_id",
          "comments",
          "attachments"
        ],
        "properties": {
          "task_id": {
            "type": "string",
            "format": "uuid"
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "description": "Descripción de la tarea; `null` si está vacía."
          },
          "comments": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlatformTaskComment"
            }
          },
          "attachments": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlatformTaskAttachment"
            }
          }
        }
      },
      "PlatformTaskComment": {
        "type": "object",
        "title": "PlatformTaskComment",
        "description": "Comentario escrito por el cliente. Nivel N2: solo se sirve dentro de `PlatformTaskContent`, declarando motivo y con su fila de auditoría.",
        "required": [
          "id",
          "body",
          "is_edited",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "author_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Autor del comentario; `null` si su cuenta ya no existe."
          },
          "body": {
            "type": "string"
          },
          "is_edited": {
            "type": "boolean"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PlatformTaskAttachment": {
        "type": "object",
        "title": "PlatformTaskAttachment",
        "description": "Metadatos de un adjunto. El NOMBRE de fichero es contenido del cliente (suele llevar el nombre de la persona o del caso), así que viaja dentro de `PlatformTaskContent` — con motivo y auditado. Los BYTES no se sirven nunca desde el panel: si hacen falta, el camino es el export de la organización, que ya se pide y se audita aparte.",
        "required": [
          "id",
          "filename",
          "content_type",
          "size_bytes",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "filename": {
            "type": "string"
          },
          "content_type": {
            "type": "string"
          },
          "size_bytes": {
            "type": "integer",
            "format": "int64"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PlatformOrgUsage": {
        "type": "object",
        "title": "PlatformOrgUsage",
        "description": "Consumo agregado de una organización para el panel ops. `storage_bytes` es la suma de los adjuntos registrados en las tablas de adjuntos (`attachments` + `task_attachments`); los avatares y logos legacy que viven fuera de esas tablas NO cuentan.",
        "required": [
          "organization_id",
          "name",
          "plan",
          "storage_bytes",
          "tasks",
          "documents",
          "attachments",
          "members",
          "projects_active",
          "projects_trashed",
          "created_at"
        ],
        "properties": {
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "examples": [
              "0b7a2d14-3c5e-4f6a-8b9c-1d2e3f405162"
            ]
          },
          "name": {
            "type": "string",
            "examples": [
              "3XA Inc"
            ]
          },
          "plan": {
            "type": "string",
            "description": "Plan efectivo de la organización (el que lee el gating).",
            "examples": [
              "projekt"
            ]
          },
          "storage_bytes": {
            "type": "integer",
            "format": "int64",
            "description": "Bytes ocupados por los adjuntos de la organización (suma de `attachments.file_size` y `task_attachments.size_bytes`). Los avatares/logos legacy almacenados fuera de esas tablas no se miden.",
            "examples": [
              10485760
            ]
          },
          "tasks": {
            "type": "integer",
            "description": "Número total de tareas de la organización.",
            "examples": [
              240
            ]
          },
          "documents": {
            "type": "integer",
            "description": "Número de documentos de la organización.",
            "examples": [
              31
            ]
          },
          "attachments": {
            "type": "integer",
            "description": "Número de adjuntos (genéricos + de tareas).",
            "examples": [
              18
            ]
          },
          "members": {
            "type": "integer",
            "description": "Número de miembros de la organización.",
            "examples": [
              12
            ]
          },
          "projects_active": {
            "type": "integer",
            "description": "Proyectos vivos (los que ocupan cupo del plan).",
            "examples": [
              3
            ]
          },
          "projects_trashed": {
            "type": "integer",
            "description": "Proyectos en la papelera (no ocupan cupo).",
            "examples": [
              5
            ]
          },
          "projects_limit": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Máximo de proyectos del plan; `null` = ilimitado.",
            "examples": [
              3
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-07-08T12:00:00Z"
            ]
          }
        }
      },
      "PlatformErrorIssue": {
        "type": "object",
        "title": "PlatformErrorIssue",
        "description": "Resultado de convertir un error 500 del panel ops en una tarea del proyecto interno de mantenimiento (PJKT-2043). La tarea nace con el contexto del error (método, ruta, mensaje, traceback truncado y request_id) para poder repararlo sin ir a buscar los logs. Idempotente: si el error ya tiene issue, se devuelve la existente en vez de crear un duplicado.",
        "required": [
          "task_id",
          "project_id",
          "organization_id",
          "created"
        ],
        "properties": {
          "task_id": {
            "type": "string",
            "format": "uuid"
          },
          "reference": {
            "type": [
              "string",
              "null"
            ],
            "description": "Referencia legible KEY-N de la tarea creada."
          },
          "project_id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "created": {
            "type": "boolean",
            "description": "`false` si la issue ya existía (se devuelve la previa)."
          }
        }
      },
      "PlatformSupplierCreate": {
        "type": "object",
        "title": "PlatformSupplierCreate",
        "description": "Datos para crear un proveedor en una organización desde el panel ops.",
        "required": [
          "organization_id",
          "name"
        ],
        "properties": {
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "description": "Organización destino del proveedor."
          },
          "name": {
            "type": "string"
          },
          "tax_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "email": {
            "type": [
              "string",
              "null"
            ]
          },
          "phone": {
            "type": [
              "string",
              "null"
            ]
          },
          "address": {
            "type": [
              "string",
              "null"
            ]
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "ErrorEvent": {
        "type": "object",
        "title": "ErrorEvent",
        "description": "Error de servidor capturado y persistido para el panel ops.",
        "required": [
          "id",
          "method",
          "path",
          "status_code",
          "message",
          "veces",
          "ultima_vez",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "request_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Request id correlacionable con el envelope de error devuelto al cliente."
          },
          "method": {
            "type": "string",
            "examples": [
              "POST"
            ]
          },
          "path": {
            "type": "string",
            "description": "Ruta de la petición (sin query string).",
            "examples": [
              "/api/v1/organizations/0b7a2d14/invoices"
            ]
          },
          "status_code": {
            "type": "integer",
            "examples": [
              500
            ]
          },
          "code": {
            "type": [
              "string",
              "null"
            ],
            "description": "Código estable del envelope de error si lo hubo (`internal_error`…)."
          },
          "message": {
            "type": "string",
            "description": "Mensaje resumido de la excepción (sin datos sensibles)."
          },
          "exception_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "Clase de la excepción Python.",
            "examples": [
              "IntegrityError"
            ]
          },
          "traceback": {
            "type": [
              "string",
              "null"
            ],
            "description": "Traceback truncado; solo visible para platform admins."
          },
          "user_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Usuario autenticado en la petición, si lo había."
          },
          "organization_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Organización inferida de la ruta, si aplica."
          },
          "issue_task_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Tarea de Projekt creada desde este error (PJKT-2043); `null` si aún no se ha convertido en issue."
          },
          "resolved_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cuándo se marcó resuelto; `null` = abierto."
          },
          "veces": {
            "type": "integer",
            "description": "Ocurrencias que representa esta fila. La captura agrupa las repeticiones de una misma firma dentro de un minuto en UNA fila con contador, así que `veces` es la magnitud real del fallo y no siempre 1.",
            "examples": [
              42
            ]
          },
          "ultima_vez": {
            "type": "string",
            "format": "date-time",
            "description": "Cuándo se contó la última ocurrencia de esta fila."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ErrorEventUpdate": {
        "type": "object",
        "title": "ErrorEventUpdate",
        "description": "Actualización de estado de un ErrorEvent.",
        "required": [
          "resolved"
        ],
        "properties": {
          "resolved": {
            "type": "boolean",
            "description": "`true` marca el error como resuelto; `false` lo reabre."
          }
        }
      },
      "ErrorGroup": {
        "type": "object",
        "title": "ErrorGroup",
        "description": "Agregado de errores capturados que comparten firma (exception_type + path).",
        "required": [
          "exception_type",
          "path",
          "count",
          "open_count",
          "first_seen",
          "last_seen",
          "last_message",
          "sample_error_id"
        ],
        "properties": {
          "exception_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "Clase de la excepción Python de la firma (`null` si no se capturó).",
            "examples": [
              "IntegrityError"
            ]
          },
          "path": {
            "type": "string",
            "description": "Ruta de la petición (sin query string) de la firma.",
            "examples": [
              "/api/v1/organizations/0b7a2d14/invoices"
            ]
          },
          "count": {
            "type": "integer",
            "description": "Ocurrencias totales de la firma (abiertas + resueltas)."
          },
          "open_count": {
            "type": "integer",
            "description": "Ocurrencias aún sin resolver."
          },
          "first_seen": {
            "type": "string",
            "format": "date-time",
            "description": "Primera ocurrencia registrada."
          },
          "last_seen": {
            "type": "string",
            "format": "date-time",
            "description": "Última ocurrencia registrada."
          },
          "last_message": {
            "type": "string",
            "description": "Mensaje de la ocurrencia más reciente."
          },
          "sample_error_id": {
            "type": "string",
            "format": "uuid",
            "description": "Id de la ocurrencia más reciente (para saltar al detalle)."
          }
        }
      },
      "ErrorGroupResolve": {
        "type": "object",
        "title": "ErrorGroupResolve",
        "description": "Firma (exception_type + path) cuyas ocurrencias abiertas se marcan resueltas.",
        "required": [
          "exception_type",
          "path"
        ],
        "properties": {
          "exception_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "Clase de la excepción de la firma; `null` empareja los errores sin excepción capturada."
          },
          "path": {
            "type": "string",
            "description": "Ruta de la petición de la firma."
          }
        }
      },
      "ErrorGroupResolveResult": {
        "type": "object",
        "title": "ErrorGroupResolveResult",
        "description": "Resultado de resolver una firma de errores en bloque.",
        "required": [
          "updated"
        ],
        "properties": {
          "updated": {
            "type": "integer",
            "description": "Ocurrencias abiertas que pasaron a resueltas (0 si ya no quedaba ninguna)."
          }
        }
      },
      "AuditLogEntry": {
        "type": "object",
        "title": "AuditLogEntry",
        "description": "Acción registrada en el audit log de la plataforma.",
        "required": [
          "id",
          "action",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "user_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Usuario que ejecutó la acción; `null` si no se resolvió (p. ej. failed_login)."
          },
          "user_email": {
            "type": [
              "string",
              "null"
            ],
            "description": "Email del usuario si sigue existiendo."
          },
          "organization_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Organización afectada; `null` en acciones de auth."
          },
          "action": {
            "type": "string",
            "description": "Código estable de la acción (`auth.login`, `member.role_changed`…).",
            "examples": [
              "auth.oauth_login"
            ]
          },
          "payload": {
            "type": [
              "object",
              "null"
            ],
            "description": "Contexto adicional de la acción (ids, sin PII).",
            "additionalProperties": true
          },
          "target_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Recurso SOBRE el que se actuó (distinto del actor); `null` si no aplica."
          },
          "target_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "Tipo del recurso afectado (`user`, `organization`, `project`…)."
          },
          "actor_ip": {
            "type": [
              "string",
              "null"
            ],
            "description": "IP de origen de la petición (IPv4/IPv6); prueba forense."
          },
          "actor_user_agent": {
            "type": [
              "string",
              "null"
            ],
            "description": "User-agent de la petición que originó la acción."
          },
          "reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Justificación aportada por el actor (p. ej. en impersonation)."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "OrgAuditLogEntry": {
        "type": "object",
        "title": "OrgAuditLogEntry",
        "description": "Acción registrada en el audit log de ESTA organización (self-service, admin+).",
        "required": [
          "id",
          "action",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "user_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Usuario que ejecutó la acción; `null` si no se resolvió."
          },
          "user_email": {
            "type": [
              "string",
              "null"
            ],
            "description": "Email del usuario si sigue existiendo."
          },
          "action": {
            "type": "string",
            "description": "Código estable de la acción (`project.created`, `member.role_changed`…).",
            "examples": [
              "project.created"
            ]
          },
          "payload": {
            "type": [
              "object",
              "null"
            ],
            "description": "Contexto adicional de la acción (ids, sin PII).",
            "additionalProperties": true
          },
          "target_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Recurso SOBRE el que se actuó (distinto del actor); `null` si no aplica."
          },
          "target_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "Tipo del recurso afectado (`project`, `task`, `member`…)."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "OrganizationDomain": {
        "type": "object",
        "title": "OrganizationDomain",
        "description": "Dominio corporativo de la organización. NUNCA incluye el código de verificación ni su hash; sí indica si hay una verificación pendiente y a qué dirección se envió, que es lo que necesita la interfaz para decir «revisa admin@acme.com».",
        "required": [
          "id",
          "domain",
          "verified",
          "enforced",
          "verification_pending",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "domain": {
            "type": "string",
            "description": "En minúsculas y sin `@`. Único de forma GLOBAL en toda la plataforma.",
            "examples": [
              "acme.com"
            ]
          },
          "verified": {
            "type": "boolean",
            "description": "`false` = reclamado pero sin efecto. Nada ocurre hasta verificarlo con un código enviado a una dirección del propio dominio."
          },
          "verified_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "enforced": {
            "type": "boolean",
            "description": "Modo obligatorio: quien tenga correo de este dominio pertenece a la organización y no puede crear organizaciones propias. Solo activable sobre un dominio verificado."
          },
          "verification_email": {
            "type": [
              "string",
              "null"
            ],
            "description": "Dirección `@dominio` a la que se envió el último código."
          },
          "verification_pending": {
            "type": "boolean",
            "description": "Hay un código vivo esperando a ser introducido."
          },
          "verification_expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cuándo caduca el código en curso (30 minutos desde su envío)."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "OrganizationDomainClaim": {
        "type": "object",
        "title": "OrganizationDomainClaim",
        "description": "Reclama el dominio y envía un código de 6 dígitos a `verification_email`, que DEBE ser una dirección del propio dominio: es la prueba de control. Reclamar no concede nada hasta verificar.",
        "required": [
          "domain",
          "verification_email"
        ],
        "properties": {
          "domain": {
            "type": "string",
            "minLength": 4,
            "maxLength": 253,
            "description": "Se normaliza (minúsculas, sin `@` ni punto final; se acepta un correo entero y se extrae el dominio). Los proveedores públicos —gmail, outlook, proton…— se rechazan con `422 public_domain`.",
            "examples": [
              "acme.com"
            ]
          },
          "verification_email": {
            "type": "string",
            "minLength": 5,
            "maxLength": 255,
            "description": "Dirección del propio dominio donde recibir el código. Si no lo es, `422 verification_email_mismatch`.",
            "examples": [
              "admin@acme.com"
            ]
          }
        }
      },
      "OrganizationDomainVerify": {
        "type": "object",
        "title": "OrganizationDomainVerify",
        "description": "Código recibido por correo. De un solo uso, caduca en 30 minutos y se invalida tras 5 intentos fallidos.",
        "required": [
          "code"
        ],
        "properties": {
          "code": {
            "type": "string",
            "minLength": 6,
            "maxLength": 6,
            "pattern": "^\\d{6}$",
            "examples": [
              "482913"
            ]
          }
        }
      },
      "OrganizationDomainEnforce": {
        "type": "object",
        "title": "OrganizationDomainEnforce",
        "description": "Al activarlo, los usuarios ya registrados con ese dominio quedan vinculados como `member` y dejan de poder crear organizaciones propias; lo que ya tenían NO se toca. Requiere el dominio verificado (`422 domain_not_verified`).",
        "required": [
          "enforced"
        ],
        "properties": {
          "enforced": {
            "type": "boolean"
          }
        }
      },
      "UnsubscribeResult": {
        "type": "object",
        "title": "UnsubscribeResult",
        "description": "Confirma la baja del canal EMAIL de una categoría. Conserva los canales in-app y web push: darse de baja del correo no debe dejar al usuario incomunicado dentro del producto.",
        "required": [
          "unsubscribed",
          "category"
        ],
        "properties": {
          "unsubscribed": {
            "type": "boolean"
          },
          "category": {
            "type": "string",
            "description": "Categoría de notificación de la que se ha dado de baja.",
            "examples": [
              "projects"
            ]
          }
        }
      },
      "PublicProfile": {
        "type": "object",
        "title": "PublicProfile",
        "description": "Lo que NO lleva es tan importante como lo que lleva: **ni email, ni organizaciones, ni nombres de proyecto, cliente o tarea**. Los datos de rendimiento son números agregados — un desglose convertiría el perfil en una lista de los clientes de la empresa.\n\nLa visibilidad la decide el propio usuario (`profile_visibility`), y nace en `private`. Cuando no se puede ver, el endpoint responde **404** y no 403: un 403 confirmaría que la cuenta existe.",
        "required": [
          "id",
          "name",
          "profile_visibility",
          "followers_count",
          "following_count",
          "viewer_is_following",
          "completed_tasks",
          "active_days"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "avatar_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "headline": {
            "type": [
              "string",
              "null"
            ]
          },
          "bio": {
            "type": [
              "string",
              "null"
            ]
          },
          "location": {
            "type": [
              "string",
              "null"
            ]
          },
          "social_links": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            },
            "description": "Enlaces a otras redes. Claves admitidas: `linkedin`, `x`, `github`, `instagram`, `youtube`, `mastodon`, `website`. Solo URLs http(s)."
          },
          "tech_stack": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Lenguajes deducidos de los repositorios de GitHub, ya agregados. Nunca nombres de repositorio, que pueden ser privados."
          },
          "profile_visibility": {
            "type": "string",
            "enum": [
              "private",
              "organization",
              "public"
            ],
            "description": "`private` = solo el propio usuario · `organization` = quien comparta alguna organización con él · `public` = cualquiera, incluso sin sesión."
          },
          "followers_count": {
            "type": "integer"
          },
          "following_count": {
            "type": "integer"
          },
          "viewer_is_following": {
            "type": "boolean",
            "description": "Si quien mira ya le sigue. `false` sin sesión o en el perfil propio."
          },
          "completed_tasks": {
            "type": "integer",
            "description": "Tareas completadas en los últimos 90 días. Número pelado, sin desglose."
          },
          "active_days": {
            "type": "integer",
            "description": "Días distintos con actividad en los últimos 90 días (constancia)."
          }
        }
      },
      "UserPost": {
        "type": "object",
        "title": "UserPost",
        "description": "Su visibilidad es la DEL PERFIL, no se decide por publicación: un ajuste por post multiplicaría los estados en los que alguien puede equivocarse y filtrar algo.",
        "required": [
          "id",
          "user_id",
          "body",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "user_id": {
            "type": "string",
            "format": "uuid"
          },
          "body": {
            "type": "string",
            "maxLength": 2000
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "UserPostCreate": {
        "type": "object",
        "title": "UserPostCreate",
        "required": [
          "body"
        ],
        "properties": {
          "body": {
            "type": "string",
            "minLength": 1,
            "maxLength": 2000
          }
        }
      },
      "ProjectFolder": {
        "type": "object",
        "title": "ProjectFolder",
        "description": "Carpeta de proyectos. Función del plan Business en adelante. Un solo nivel: no hay carpetas dentro de carpetas.\n\nLa carpeta SÍ tiene control de acceso propio (`visibility` + `visibility_departments`, al estilo de los departamentos de un proyecto): decide quién VE la carpeta en la barra y quién puede filtrar por ella. Lo que NO hace es decidir quién ve un PROYECTO: eso lo siguen resolviendo la visibilidad del proyecto, sus miembros explícitos y sus departamentos. Un proyecto dentro de una carpeta restringida sigue apareciendo en «Todas» para quien pueda verlo.",
        "required": [
          "id",
          "name",
          "position",
          "project_count",
          "visibility",
          "visibility_departments",
          "pinned",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "maxLength": 120,
            "description": "Único dentro de la organización.",
            "examples": [
              "Clientes"
            ]
          },
          "color": {
            "type": [
              "string",
              "null"
            ],
            "description": "Color hex de la etiqueta (`#RRGGBB`); `null` = neutra.",
            "examples": [
              "#FD2554"
            ]
          },
          "position": {
            "type": "integer",
            "description": "Orden manual. A igualdad, se ordena por nombre (orden estable)."
          },
          "project_count": {
            "type": "integer",
            "description": "Proyectos NO borrados que contiene, para pintar «Clientes (7)»."
          },
          "visibility": {
            "type": "string",
            "enum": [
              "organization",
              "restricted"
            ],
            "description": "Quién ve la carpeta. `organization` (por defecto, y valor de TODAS las carpetas preexistentes): cualquier miembro de la organización. `restricted`: solo owner/admin y los miembros de los departamentos de `visibility_departments`. Quien no tiene acceso NI la ve en el listado NI puede filtrar por ella.",
            "examples": [
              "organization"
            ]
          },
          "visibility_departments": {
            "type": "array",
            "description": "Departamentos (M:N `project_folder_departments`) cuyos miembros ven la carpeta cuando es `restricted` — uno o VARIOS; el caller accede si pertenece a CUALQUIERA de ellos. Vacío en una carpeta `restricted` = solo owner/admin. Irrelevante mientras la carpeta sea `organization`.",
            "items": {
              "type": "string",
              "format": "uuid"
            }
          },
          "pinned": {
            "type": "boolean",
            "default": false,
            "description": "`true` si QUIEN LLAMA ha fijado esta carpeta (chincheta) como filtro por defecto del listado de proyectos. Es una preferencia por USUARIO y por ORGANIZACIÓN (vive en su membresía), no un atributo de la carpeta: dos personas de la misma organización ven `pinned` distinto en la misma carpeta.\n\nComo mucho UNA carpeta de la respuesta lo trae a `true` (fijar otra sustituye la anterior). Si la carpeta fijada se borra o el usuario deja de tener acceso a ella, ninguna la trae a `true` y el listado vuelve a «Todas» — la preferencia se limpia sola.",
            "examples": [
              false
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ProjectFolderCreate": {
        "type": "object",
        "title": "ProjectFolderCreate",
        "description": "Crea una carpeta. Requiere rol `admin`/`owner` y plan Business (`403 feature_not_available` en el resto).",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120,
            "description": "Único en la organización; repetirlo da `409 folder_name_taken`."
          },
          "color": {
            "type": [
              "string",
              "null"
            ],
            "pattern": "^#[0-9A-Fa-f]{6}$"
          },
          "position": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "description": "Si se omite, la carpeta se añade AL FINAL sin reordenar las demás."
          },
          "visibility": {
            "type": "string",
            "enum": [
              "organization",
              "restricted"
            ],
            "description": "Quién verá la carpeta. Si se omite, `organization` (toda la organización). Con `restricted`, los departamentos con acceso se fijan después con `setProjectFolderDepartments`; hasta entonces solo la ven owner/admin."
          }
        }
      },
      "ProjectFolderUpdate": {
        "type": "object",
        "title": "ProjectFolderUpdate",
        "description": "Renombra, recolorea, reordena la carpeta o cambia quién la ve. Solo se aplica lo enviado.",
        "properties": {
          "name": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 1,
            "maxLength": 120
          },
          "color": {
            "type": [
              "string",
              "null"
            ],
            "pattern": "^#[0-9A-Fa-f]{6}$"
          },
          "position": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0
          },
          "visibility": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "organization",
              "restricted",
              null
            ],
            "description": "Cambia el alcance de la carpeta. Pasarla a `organization` NO borra los departamentos vinculados: quedan guardados y vuelven a aplicar si se restringe otra vez."
          }
        }
      },
      "PlatformAdmin": {
        "type": "object",
        "title": "PlatformAdmin",
        "description": "Administrador de plataforma (acceso al panel ops).",
        "required": [
          "user_id",
          "email",
          "name",
          "created_at"
        ],
        "properties": {
          "user_id": {
            "type": "string",
            "format": "uuid"
          },
          "email": {
            "type": "string",
            "format": "email",
            "examples": [
              "nick@3xa.es"
            ]
          },
          "name": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "Cuándo se añadió a la allowlist."
          }
        }
      },
      "PlatformAdminCreate": {
        "type": "object",
        "title": "PlatformAdminCreate",
        "description": "Alta de un administrador de plataforma por email.",
        "required": [
          "email"
        ],
        "properties": {
          "email": {
            "type": "string",
            "format": "email",
            "description": "Email de un usuario existente con dominio permitido.",
            "examples": [
              "nick@3xa.es"
            ]
          }
        }
      },
      "PlatformAdminSession": {
        "type": "object",
        "title": "PlatformAdminSession",
        "description": "Sesión activa de un platform admin (accesos vivos al panel).",
        "required": [
          "user_id",
          "user_name",
          "user_email",
          "session_id",
          "created_at",
          "is_current"
        ],
        "properties": {
          "user_id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID del admin dueño de la sesión."
          },
          "user_name": {
            "type": "string",
            "description": "Nombre visible del admin."
          },
          "user_email": {
            "type": "string",
            "format": "email",
            "description": "Email del admin."
          },
          "session_id": {
            "type": "string",
            "format": "uuid",
            "description": "UUID del refresh token (nunca el token en sí). Sirve para revocar via DELETE /platform/users/{user_id}/sessions/{session_id}."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "Cuándo se abrió la sesión."
          },
          "last_used_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Última rotación del refresh (actividad); `null` si nunca se refrescó."
          },
          "ip": {
            "type": [
              "string",
              "null"
            ],
            "description": "IP (IPv4/IPv6) desde la que se abrió/rotó la sesión; `null` en sesiones anteriores a PJKT-1889."
          },
          "user_agent": {
            "type": [
              "string",
              "null"
            ],
            "description": "User-Agent del navegador/dispositivo; `null` en sesiones legacy."
          },
          "is_current": {
            "type": "boolean",
            "description": "True SOLO para la sesión con la que se hace ESTA petición (el refresh de la cookie del llamante). El panel la etiqueta «esta sesión» y no permite revocarla desde la tarjeta (auto-cerrarse la sesión sería un footgun)."
          }
        }
      },
      "PlatformOrganizationDetail": {
        "type": "object",
        "title": "PlatformOrganizationDetail",
        "description": "Organización con miembros, contadores y última actividad (vista ops).",
        "required": [
          "id",
          "name",
          "slug",
          "status",
          "plan_usage",
          "created_at",
          "members",
          "project_count",
          "trashed_project_count",
          "task_counts",
          "invoice_count",
          "document_count",
          "supplier_count"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "slug": {
            "type": "string"
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "brand_color": {
            "type": [
              "string",
              "null"
            ]
          },
          "legal_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Razón social legal; `null` si no configurada."
          },
          "tax_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "NIF/CIF de la organización; `null` si no configurado."
          },
          "fiscal_address": {
            "type": [
              "string",
              "null"
            ],
            "description": "Domicilio fiscal; `null` si no configurado."
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "suspended",
              "deleted"
            ],
            "description": "Estado de la cuenta derivado de `deleted_at`/`suspended_at` (borrada gana sobre suspendida). Hasta ahora la ficha no decía si la org estaba suspendida: solo miraba `deleted_at`."
          },
          "suspended_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Suspendida desde; `null` si no lo está."
          },
          "suspend_reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Motivo registrado al suspender, si lo hubo."
          },
          "plan_usage": {
            "$ref": "#/components/schemas/PlatformPlanUsage"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "members": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlatformOrgMember"
            }
          },
          "project_count": {
            "type": "integer",
            "description": "Proyectos VIVOS de la organización: cuadra con la tabla de proyectos de la misma ficha y es lo que ocupa cupo del plan."
          },
          "trashed_project_count": {
            "type": "integer",
            "description": "Proyectos de la organización en la papelera (soft-deleted). No ocupan cupo del plan; se enseñan aparte para poder responder «lo borré y sigo bloqueado»."
          },
          "task_counts": {
            "type": "object",
            "description": "Tareas por estado (clave = estado del tablero, p. ej. todo/in_progress/done), excluyendo las de proyectos en la papelera para que cuadre con la tabla de tareas.",
            "additionalProperties": {
              "type": "integer"
            }
          },
          "invoice_count": {
            "type": "integer"
          },
          "document_count": {
            "type": "integer"
          },
          "supplier_count": {
            "type": "integer"
          },
          "last_activity_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Timestamp de la última entrada de audit de la organización."
          },
          "deleted_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Soft-delete (W1). `null` = org viva. El panel muestra la \"Zona de peligro\" (purga irreversible) SOLO cuando no es `null`."
          },
          "purge_eligible_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Instante a partir del cual la org soft-deleted es purgable (`deleted_at` + periodo de gracia). `null` si la org está viva. El panel pinta el countdown y habilita la purga cuando este instante ya pasó."
          }
        }
      },
      "PlatformOrganizationUpdate": {
        "type": "object",
        "title": "PlatformOrganizationUpdate",
        "description": "PATCH parcial de una organización desde el panel ops (PJKT-2032): corregir el nombre o los datos fiscales (razón social, NIF/CIF, domicilio fiscal) sin entrar como miembro. Solo cambian los campos presentes; `null` limpia los anulables. El `slug` no se toca desde ops: la propia organización lo cambia desde sus ajustes (`PATCH /organizations/{org_id}`), donde el 409 `slug_taken` va dirigido a quien lo está eligiendo.",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255,
            "description": "Nombre visible de la organización. No admite `null`.",
            "examples": [
              "Cliente Renombrado SL"
            ]
          },
          "legal_name": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255,
            "description": "Razón social legal; `null` la limpia.",
            "examples": [
              "Cliente Sociedad Limitada"
            ]
          },
          "tax_id": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 20,
            "description": "NIF/CIF; `null` lo limpia.",
            "examples": [
              "B12345678"
            ]
          },
          "fiscal_address": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 2000,
            "description": "Domicilio fiscal; `null` lo limpia.",
            "examples": [
              "Calle Mayor 1, 28001 Madrid, España"
            ]
          }
        }
      },
      "PlatformOrgMember": {
        "type": "object",
        "title": "PlatformOrgMember",
        "description": "Miembro de una organización (vista modo dios).",
        "required": [
          "user_id",
          "email",
          "name",
          "role"
        ],
        "properties": {
          "user_id": {
            "type": "string",
            "format": "uuid"
          },
          "email": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "avatar_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "role": {
            "type": "string",
            "enum": [
              "owner",
              "admin",
              "manager",
              "member"
            ]
          }
        }
      },
      "PlatformTimeseriesPoint": {
        "type": "object",
        "title": "PlatformTimeseriesPoint",
        "description": "Métricas agregadas de un día natural (UTC) de toda la plataforma.",
        "required": [
          "date",
          "new_users",
          "new_organizations",
          "logins",
          "errors"
        ],
        "properties": {
          "date": {
            "type": "string",
            "format": "date",
            "examples": [
              "2026-07-18"
            ]
          },
          "new_users": {
            "type": "integer",
            "description": "Altas de usuarios ese día."
          },
          "new_organizations": {
            "type": "integer",
            "description": "Organizaciones creadas ese día."
          },
          "logins": {
            "type": "integer",
            "description": "Logins (audit auth.login + auth.oauth_login + auth.magic_link_login + auth.passkey_login)."
          },
          "errors": {
            "type": "integer",
            "description": "Errores capturados ese día."
          }
        }
      },
      "PlatformUserBulkAction": {
        "type": "object",
        "title": "PlatformUserBulkAction",
        "description": "Acción en lote sobre usuarios desde el panel de plataforma. `ids` es la selección (1–100 uuids); `action` el verbo. `confirm` DEBE ser `true` para `soft_delete` (irreversible: marca `deleted_at` y anonimiza el email) o el servidor responde 422 `confirmation_required`. `suspend` corta las sesiones activas del usuario; `unsuspend` reactiva. Idempotente: un usuario ya en el estado destino se cuenta en `skipped`, no es error.",
        "required": [
          "ids",
          "action"
        ],
        "properties": {
          "ids": {
            "type": "array",
            "minItems": 1,
            "maxItems": 100,
            "description": "IDs de usuario (máximo 100 por lote).",
            "items": {
              "type": "string",
              "format": "uuid"
            }
          },
          "action": {
            "type": "string",
            "description": "`suspend` (status=suspended + revoca sesiones), `unsuspend` (status=active) o `soft_delete` (deleted_at + email anonimizado).",
            "enum": [
              "suspend",
              "unsuspend",
              "soft_delete"
            ]
          },
          "confirm": {
            "type": "boolean",
            "default": false,
            "description": "Obligatorio `true` para `soft_delete`."
          }
        }
      },
      "PlatformOrgBulkAction": {
        "type": "object",
        "title": "PlatformOrgBulkAction",
        "description": "Acción en lote sobre organizaciones desde el panel de plataforma. `ids` es la selección (1–100 uuids); `action` el verbo. `confirm` DEBE ser `true` para `soft_delete` (marca `deleted_at`; el purge físico en cascada es otra fase) o el servidor responde 422 `confirmation_required`. `set_plan` otorga un plan a mano (override manual, reversible, NO exige `confirm`): requiere `plan` —si falta, 422 `validation_error`—; una org con suscripción de Stripe ACTIVA (active/trialing/past_due) NO se pisa y va a `errors` con motivo `managed_by_stripe`. Idempotente: una org ya en el estado destino (o ya en ese plan por override manual) se cuenta en `skipped`, no es error.",
        "required": [
          "ids",
          "action"
        ],
        "properties": {
          "ids": {
            "type": "array",
            "minItems": 1,
            "maxItems": 100,
            "description": "IDs de organización (máximo 100 por lote).",
            "items": {
              "type": "string",
              "format": "uuid"
            }
          },
          "action": {
            "type": "string",
            "description": "`suspend` (suspended_at=now), `unsuspend` (suspended_at=null), `soft_delete` (deleted_at=now) o `set_plan` (override manual de plan; requiere `plan`).",
            "enum": [
              "suspend",
              "unsuspend",
              "soft_delete",
              "set_plan"
            ]
          },
          "plan": {
            "type": [
              "string",
              "null"
            ],
            "description": "Plan a otorgar. OBLIGATORIO cuando `action` es `set_plan` (si falta —o llega `null`—, el servidor responde 422 `validation_error`); ignorado para el resto de acciones, donde `null` es lo mismo que omitirlo. Se declara nulable porque el API lo acepta así: prohibirlo aquí solo hacía que el SDK rechazara un cuerpo que el servidor procesa igual.",
            "enum": [
              "free",
              "equipo",
              "projekt",
              null
            ]
          },
          "confirm": {
            "type": "boolean",
            "default": false,
            "description": "Obligatorio `true` para `soft_delete`."
          }
        }
      },
      "PlatformSupplierBulkAction": {
        "type": "object",
        "title": "PlatformSupplierBulkAction",
        "description": "Acción en lote sobre proveedores (tabla `suppliers` org-scoped) desde el panel de plataforma. `ids` es la selección (1–100 uuids); `action` el verbo. `confirm` DEBE ser `true` para `delete` y `merge` o el servidor responde 422 `confirmation_required`. En `merge`, `target_id` es OBLIGATORIO: los gastos y facturas de proveedor de cada fuente se repuntan al destino y la fuente se borra. El merge cross-org está PROHIBIDO (error `merge_cross_org` por id). `promote` sube cada proveedor al catálogo global; NO exige `confirm` (no destruye nada y es idempotente: los ya promocionados cuentan como éxito).",
        "required": [
          "ids",
          "action"
        ],
        "properties": {
          "ids": {
            "type": "array",
            "minItems": 1,
            "maxItems": 100,
            "description": "IDs de proveedor a borrar o fusionar (máximo 100 por lote).",
            "items": {
              "type": "string",
              "format": "uuid"
            }
          },
          "action": {
            "type": "string",
            "description": "`delete` (borra), `merge` (repunta referencias al `target_id` y borra) o `promote` (sube al catálogo global, idempotente).",
            "enum": [
              "delete",
              "merge",
              "promote"
            ]
          },
          "target_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Proveedor superviviente de la fusión. Obligatorio cuando `action` es `merge`; debe ser de la MISMA organización que cada fuente."
          },
          "confirm": {
            "type": "boolean",
            "default": false,
            "description": "Obligatorio `true` para `delete` y `merge`."
          }
        }
      },
      "PlatformHealth": {
        "type": "object",
        "title": "PlatformHealth",
        "description": "Observabilidad READ-ONLY de la infraestructura para operaciones: colas de trabajos, heartbeat del scheduler, base de datos, Redis y tasa de errores. `status` es el veredicto agregado derivado de las sondas. La respuesta NUNCA contiene DSN, URLs con credenciales ni secretos.",
        "required": [
          "status",
          "generated_at",
          "database",
          "redis",
          "scheduler",
          "queues",
          "errors",
          "web_push",
          "backups"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "ok",
              "degraded",
              "down"
            ],
            "description": "Veredicto agregado. `down` si DB o Redis no responden; `degraded` si el scheduler está caído, hay backlog/dead-letter en alguna cola o la tasa de errores es alta; `ok` en caso contrario."
          },
          "generated_at": {
            "type": "string",
            "format": "date-time",
            "description": "Momento en que se calculó el agregado (UTC)."
          },
          "database": {
            "$ref": "#/components/schemas/PlatformHealthDatabase"
          },
          "redis": {
            "$ref": "#/components/schemas/PlatformHealthRedis"
          },
          "scheduler": {
            "$ref": "#/components/schemas/PlatformHealthScheduler"
          },
          "queues": {
            "type": "array",
            "description": "Profundidad por cola de trabajos Dramatiq.",
            "items": {
              "$ref": "#/components/schemas/PlatformQueueDepth"
            }
          },
          "errors": {
            "$ref": "#/components/schemas/PlatformHealthErrors"
          },
          "web_push": {
            "$ref": "#/components/schemas/PlatformHealthWebPush"
          },
          "backups": {
            "$ref": "#/components/schemas/PlatformHealthBackups"
          }
        }
      },
      "PlatformHealthDatabase": {
        "type": "object",
        "title": "PlatformHealthDatabase",
        "description": "Sonda de la base de datos: latencia de un `SELECT 1` y estado del pool de conexiones del engine async. Nunca incluye el DSN ni credenciales.",
        "required": [
          "ok"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "description": "true si el SELECT 1 respondió sin error."
          },
          "latency_ms": {
            "type": [
              "number",
              "null"
            ],
            "description": "Latencia del SELECT 1 en milisegundos; null si falló.",
            "examples": [
              1.8
            ]
          },
          "pool_size": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Tamaño configurado del pool (null si el pool no lo expone)."
          },
          "pool_checked_in": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Conexiones libres en el pool."
          },
          "pool_checked_out": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Conexiones en uso ahora mismo."
          },
          "pool_overflow": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Conexiones overflow abiertas por encima del tamaño base."
          }
        }
      },
      "PlatformHealthRedis": {
        "type": "object",
        "title": "PlatformHealthRedis",
        "description": "Sonda de Redis: latencia de PING y un subconjunto ALLOWLIST de `INFO` (memoria, clientes, uptime, versión). Nunca incluye la URL ni la password.",
        "required": [
          "ok"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "description": "true si el PING respondió PONG."
          },
          "latency_ms": {
            "type": [
              "number",
              "null"
            ],
            "description": "Latencia del PING en milisegundos; null si falló.",
            "examples": [
              0.4
            ]
          },
          "used_memory_bytes": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Memoria usada por Redis en bytes (INFO used_memory)."
          },
          "connected_clients": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Clientes conectados (INFO connected_clients)."
          },
          "uptime_seconds": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Uptime del servidor Redis en segundos."
          },
          "version": {
            "type": [
              "string",
              "null"
            ],
            "description": "Versión del servidor Redis (INFO redis_version).",
            "examples": [
              "7.2.4"
            ]
          }
        }
      },
      "PlatformHealthScheduler": {
        "type": "object",
        "title": "PlatformHealthScheduler",
        "description": "Latido del scheduler de tareas periódicas (proceso `periodiq`). Los actores periódicos sellan un timestamp en Redis en cada ejecución; si el más reciente supera `stale_after_seconds`, el beat se considera caído. Limitación: la señal vive en Redis con TTL — si Redis se vacía, se pierde hasta el siguiente barrido.",
        "required": [
          "alive",
          "stale_after_seconds"
        ],
        "properties": {
          "alive": {
            "type": "boolean",
            "description": "true si hay un beat reciente (dentro de la ventana de obsolescencia)."
          },
          "last_beat_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Último latido conocido; null si no hay señal (beat caído o Redis vacío)."
          },
          "stale_after_seconds": {
            "type": "integer",
            "description": "Segundos sin latido tras los que se considera obsoleto/caído.",
            "examples": [
              2700
            ]
          }
        }
      },
      "PlatformHealthErrors": {
        "type": "object",
        "title": "PlatformHealthErrors",
        "description": "Conteo de errores de servidor (tabla `error_events`) en las últimas ventanas. Señal de tasa de error para detectar incidentes antes que el operador.",
        "required": [
          "last_hour",
          "last_24h"
        ],
        "properties": {
          "last_hour": {
            "type": "integer",
            "description": "Errores 500 capturados en la última hora.",
            "examples": [
              0
            ]
          },
          "last_24h": {
            "type": "integer",
            "description": "Errores 500 capturados en las últimas 24 horas.",
            "examples": [
              3
            ]
          }
        }
      },
      "PlatformHealthWebPush": {
        "type": "object",
        "title": "PlatformHealthWebPush",
        "required": [
          "enabled",
          "subject_configured",
          "subscriptions"
        ],
        "properties": {
          "enabled": {
            "type": "boolean",
            "description": "¿Hay claves VAPID (pública y privada) configuradas? `false` ⇒ el canal está apagado: in-app y email siguen funcionando, el push no sale.",
            "examples": [
              true
            ]
          },
          "subject_configured": {
            "type": "boolean",
            "description": "¿Hay `VAPID_SUBJECT` (mailto: de contacto, exigido por RFC 8292)?",
            "examples": [
              true
            ]
          },
          "subscriptions": {
            "type": "integer",
            "minimum": 0,
            "description": "Suscripciones vivas. `enabled: true` con `subscriptions: 0` significa canal listo pero sin nadie que haya dado permiso todavía — matiz que un booleano solo no distingue.",
            "examples": [
              42
            ]
          }
        }
      },
      "PlatformHealthBackups": {
        "type": "object",
        "title": "PlatformHealthBackups",
        "required": [
          "stale"
        ],
        "properties": {
          "last_backup_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Fecha (mtime, UTC) del artefacto de backup más reciente; null si el directorio no existe o está vacío (p. ej. en desarrollo local)."
          },
          "size_bytes": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "description": "Tamaño en bytes de ese artefacto más reciente; null si no hay ninguno."
          },
          "stale": {
            "type": "boolean",
            "description": "true si el último backup tiene más de 26 h (el cron es diario: 24 h + margen) o si no hay ninguno. Es la señal de alerta que el panel pinta en rojo."
          }
        }
      },
      "PlatformQueueDepth": {
        "type": "object",
        "title": "PlatformQueueDepth",
        "description": "Estado de una cola de trabajos Dramatiq sobre Redis. `pending` es el backlog aún sin recoger por un worker (LLEN de la lista `dramatiq:<cola>`); `dead_letter` los mensajes descartados tras agotar reintentos (ZCARD de `dramatiq:<cola>.XQ`).",
        "required": [
          "name",
          "pending",
          "dead_letter"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "Nombre de la cola (p. ej. emails, push, pdfs, cleanup, scheduled).",
            "examples": [
              "emails"
            ]
          },
          "pending": {
            "type": "integer",
            "description": "Mensajes en espera de ser procesados (backlog).",
            "examples": [
              0
            ]
          },
          "dead_letter": {
            "type": "integer",
            "description": "Mensajes en la dead-letter queue (fallaron tras agotar reintentos).",
            "examples": [
              0
            ]
          }
        }
      },
      "PlatformJobStatus": {
        "type": "object",
        "title": "PlatformJobStatus",
        "description": "Estado de un job periódico del scheduler (`periodiq`). El registro declarativo del código aporta el nombre y la cadencia (`schedule`); el último latido en Redis (hash `jobs:heartbeat:{name}`, TTL 30 días) aporta última ejecución, duración y resultado. `last_run_at`/`duration_ms`/`ok` son null si el job no ha corrido nunca (o el latido caducó / Redis se vació).",
        "required": [
          "name",
          "schedule"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "Nombre del actor periódico (p. ej. `scan_due_tasks`).",
            "examples": [
              "scan_due_tasks"
            ]
          },
          "last_run_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Fin de la última ejecución conocida; null si nunca corrió."
          },
          "duration_ms": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Duración de la última ejecución en milisegundos; null si nunca corrió."
          },
          "ok": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "true si la última ejecución terminó sin excepción; false si reventó; null si nunca corrió."
          },
          "schedule": {
            "type": "string",
            "description": "Cadencia declarada, legible («cada hora en punto», «diario 03:30 UTC»).",
            "examples": [
              "cada hora en punto"
            ]
          }
        }
      },
      "QueueStatus": {
        "type": "object",
        "title": "QueueStatus",
        "description": "Estado de una cola de trabajos Dramatiq sobre Redis. `depth` es el backlog listo para consumir (LLEN de `dramatiq:<cola>`); `delayed` los mensajes programados a futuro aún no promocionados (LLEN de `dramatiq:<cola>.DQ`); `dead` los descartados tras agotar reintentos (ZCARD de `dramatiq:<cola>.XQ`).",
        "required": [
          "name",
          "depth",
          "delayed",
          "dead"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "Nombre de la cola (p. ej. emails, push, pdfs, cleanup, scheduled).",
            "examples": [
              "emails"
            ]
          },
          "depth": {
            "type": "integer",
            "description": "Mensajes en espera de ser recogidos por un worker (backlog).",
            "examples": [
              0
            ]
          },
          "delayed": {
            "type": "integer",
            "description": "Mensajes con entrega diferida (cola `.DQ`) pendientes de promoción.",
            "examples": [
              0
            ]
          },
          "dead": {
            "type": "integer",
            "description": "Mensajes en la dead-letter queue (`.XQ`), fallidos tras agotar reintentos.",
            "examples": [
              0
            ]
          }
        }
      },
      "PlatformFeatureFlag": {
        "type": "object",
        "title": "PlatformFeatureFlag",
        "description": "Estado del kill-switch GLOBAL de una feature. `enabled=null` = no hay flag global (la feature sigue el default del plan de cada org); `true`/`false` = kill-switch global explícito para TODAS las organizaciones.",
        "required": [
          "key",
          "min_plan",
          "enabled"
        ],
        "properties": {
          "key": {
            "type": "string",
            "description": "Clave de la feature (de FEATURE_MIN_PLAN, p. ej. `finance`, `crm`)."
          },
          "min_plan": {
            "type": "string",
            "description": "Plan mínimo que incluye la feature por defecto.",
            "enum": [
              "free",
              "equipo",
              "projekt"
            ]
          },
          "enabled": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Override global; `null` = sin flag global (default del plan)."
          },
          "note": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nota/justificación forense del admin."
          },
          "updated_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Última vez que se fijó el flag global; `null` si no existe."
          },
          "updated_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Platform admin que fijó el flag por última vez."
          }
        }
      },
      "PlatformOrgFeatureFlag": {
        "type": "object",
        "title": "PlatformOrgFeatureFlag",
        "description": "Estado efectivo de una feature para una organización, con el desglose de la precedencia (decisión D6): override de org > flag global > default del plan. `source` indica de qué ámbito sale el valor `effective`.",
        "required": [
          "key",
          "min_plan",
          "plan_default",
          "global_enabled",
          "override_enabled",
          "effective",
          "source"
        ],
        "properties": {
          "key": {
            "type": "string",
            "description": "Clave de la feature (de FEATURE_MIN_PLAN)."
          },
          "min_plan": {
            "type": "string",
            "description": "Plan mínimo que incluye la feature por defecto.",
            "enum": [
              "free",
              "equipo",
              "projekt"
            ]
          },
          "plan_default": {
            "type": "boolean",
            "description": "`has_feature(org.plan, key)`: si el plan de la org la incluye por defecto."
          },
          "global_enabled": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Valor del flag GLOBAL; `null` = sin flag global."
          },
          "override_enabled": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Valor del override de ESTA org; `null` = sin override de org."
          },
          "effective": {
            "type": "boolean",
            "description": "Resultado efectivo tras aplicar la precedencia."
          },
          "source": {
            "type": "string",
            "description": "De dónde sale `effective`.",
            "enum": [
              "org",
              "global",
              "plan"
            ]
          },
          "note": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nota del override de org (si existe)."
          },
          "updated_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cuándo se fijó el override de org; `null` si no existe."
          },
          "updated_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Platform admin que fijó el override de org por última vez."
          }
        }
      },
      "PlatformFeatureFlagSet": {
        "type": "object",
        "title": "PlatformFeatureFlagSet",
        "description": "Fija (upsert) un feature flag. Sirve tanto para el kill-switch global como para el override de una org (el ámbito lo determina la ruta). `key` debe ser una feature conocida de FEATURE_MIN_PLAN (si no, 422 `validation_error`).",
        "required": [
          "key",
          "enabled"
        ],
        "properties": {
          "key": {
            "type": "string",
            "description": "Clave de la feature a habilitar/deshabilitar (de FEATURE_MIN_PLAN)."
          },
          "enabled": {
            "type": "boolean",
            "description": "`true` habilita la feature en ese ámbito; `false` la deshabilita."
          },
          "note": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255,
            "description": "Nota/justificación forense opcional (queda en el audit)."
          }
        }
      },
      "PlatformExport": {
        "type": "object",
        "title": "PlatformExport",
        "description": "Estado de un export de datos de una organización. El ZIP resultante NUNCA se sirve desde una ruta pública: `download_url` (presente solo cuando `status=done`) apunta al endpoint de descarga AUTENTICADO (requiere sesión de platform admin). `error` (solo si `failed`) es un resumen corto, jamás un stacktrace.",
        "required": [
          "id",
          "organization_id",
          "status",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Identificador del export."
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "description": "Organización cuyos datos se exportan."
          },
          "status": {
            "type": "string",
            "description": "Estado del job de export. `expired` es terminal: el ZIP ya se ha retirado del almacenamiento (por caducidad, por purga de la organización o por una supresión GDPR) y la fila se conserva solo como constancia.",
            "enum": [
              "queued",
              "running",
              "done",
              "failed",
              "expired"
            ]
          },
          "error": {
            "type": [
              "string",
              "null"
            ],
            "description": "Resumen corto del fallo (solo `failed`); `null` en otro caso."
          },
          "requested_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Platform admin que pidió el export (`null` si se borró después)."
          },
          "download_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Ruta del endpoint de descarga AUTENTICADO (`/api/v1/platform/exports/{id}/download`); presente solo cuando `status=done`. No es una URL pública: exige la sesión de platform admin."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "Cuándo se pidió el export."
          },
          "completed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cuándo terminó (done/failed); `null` mientras sigue en cola/corriendo."
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cuándo deja de conservarse el ZIP (`EXPORT_RETENTION_DAYS` tras terminar). Pasada esa fecha el fichero se retira del almacenamiento y la descarga responde 404. `null` = no caduca (export anterior a la caducidad, o retención desactivada)."
          }
        }
      },
      "PlatformPurge": {
        "type": "object",
        "title": "PlatformPurge",
        "description": "Estado de una purga de organización (borrado físico IRREVERSIBLE en cascada). `organization_id` es `null` una vez ejecutada (la org fue borrada: su FK cayó a NULL); `org_name` es el snapshot que sobrevive al borrado. `error` (solo si `failed`) es un resumen corto — la salvaguarda que dejó de cumplirse o el fallo —, jamás un stacktrace.",
        "required": [
          "id",
          "org_name",
          "status",
          "scheduled_at",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Identificador de la purga."
          },
          "organization_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Org a purgar; `null` una vez ejecutada (la org fue borrada)."
          },
          "org_name": {
            "type": "string",
            "description": "Snapshot del nombre de la org (sobrevive al borrado)."
          },
          "status": {
            "type": "string",
            "description": "Estado del job de purga.",
            "enum": [
              "queued",
              "running",
              "done",
              "failed"
            ]
          },
          "scheduled_at": {
            "type": "string",
            "format": "date-time",
            "description": "Instante a partir del cual es elegible (>= `deleted_at` + gracia)."
          },
          "requested_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Platform admin que pidió la purga (`null` si se borró después)."
          },
          "error": {
            "type": [
              "string",
              "null"
            ],
            "description": "Resumen corto del fallo/aborto (solo `failed`); `null` en otro caso."
          },
          "executed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cuándo terminó (done/failed); `null` mientras sigue en cola/corriendo."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "Cuándo se pidió la purga."
          }
        }
      },
      "PlatformPurgeRequest": {
        "type": "object",
        "title": "PlatformPurgeRequest",
        "description": "Cuerpo para pedir la purga (borrado físico irreversible) de una organización. `confirm_name` DEBE coincidir EXACTO con el nombre de la org (si no, 422 `confirm_name_mismatch`). `reason` es forense: queda en la auditoría.",
        "required": [
          "confirm_name"
        ],
        "properties": {
          "confirm_name": {
            "type": "string",
            "minLength": 1,
            "description": "Nombre EXACTO de la organización, tecleado a mano como confirmación (anti-fat-finger). Debe coincidir con `name` de la org."
          },
          "reason": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255,
            "description": "Justificación de la purga (forense; queda en la auditoría)."
          }
        }
      },
      "ImpersonationCreate": {
        "type": "object",
        "title": "ImpersonationCreate",
        "description": "Petición para abrir una sesión de impersonation (login-as, SOLO LECTURA) sobre un usuario. `reason` es obligatorio y queda en la fila y en la auditoría.",
        "required": [
          "reason"
        ],
        "properties": {
          "reason": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255,
            "description": "Motivo (obligatorio) por el que soporte abre la sesión. Forense.",
            "examples": [
              "Ticket #4821: el usuario reporta que no ve sus facturas"
            ]
          },
          "organization_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Contexto opcional: la organización que el soporte quiere inspeccionar. Si viene, el usuario impersonado debe ser miembro de ella (si no, 422).",
            "examples": [
              "6f1f9c3e-8f2a-4b6e-9a3d-2b1c0d4e5f60"
            ]
          }
        }
      },
      "ImpersonationCreated": {
        "type": "object",
        "title": "ImpersonationCreated",
        "description": "Sesión de impersonation recién creada. El `token` opaco se devuelve UNA sola vez (en DB solo vive su SHA-256). El panel ops abre `activation_url` (navegación top-level) para que el API fije la cookie HttpOnly de impersonation en el dominio de la web y redirija al dashboard.",
        "required": [
          "session_id",
          "impersonated_user_id",
          "token",
          "activation_url",
          "expires_at"
        ],
        "properties": {
          "session_id": {
            "type": "string",
            "format": "uuid",
            "description": "Identificador de la sesión de impersonation (para revocarla)."
          },
          "impersonated_user_id": {
            "type": "string",
            "format": "uuid",
            "description": "Usuario que se va a ver."
          },
          "token": {
            "type": "string",
            "description": "Token OPACO de la sesión (prefijo `pjk_imp_`). Se devuelve UNA vez; guárdalo solo el tiempo de abrir la web. Nunca se persiste en claro."
          },
          "activation_url": {
            "type": "string",
            "description": "Enlace que el panel ops abre para activar la sesión: fija la cookie HttpOnly y redirige a `{WEB_URL}/dashboard`."
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "Fin del time-box corto (20 min) de la sesión."
          }
        }
      },
      "ImpersonationSession": {
        "type": "object",
        "title": "ImpersonationSession",
        "description": "Sesión de impersonation activa (no revocada ni caducada). Solo metadata forense; el token nunca viaja.",
        "required": [
          "id",
          "impersonator_id",
          "impersonator_email",
          "impersonated_user_id",
          "impersonated_email",
          "reason",
          "created_at",
          "expires_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Identificador de la sesión (para revocarla)."
          },
          "impersonator_id": {
            "type": "string",
            "format": "uuid",
            "description": "Platform admin que abrió la sesión."
          },
          "impersonator_email": {
            "type": "string",
            "format": "email",
            "description": "Email del platform admin que impersona."
          },
          "impersonated_user_id": {
            "type": "string",
            "format": "uuid",
            "description": "Usuario que se está viendo."
          },
          "impersonated_email": {
            "type": "string",
            "format": "email",
            "description": "Email del usuario impersonado."
          },
          "organization_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Organización de contexto (`null` si no se fijó)."
          },
          "reason": {
            "type": "string",
            "description": "Motivo forense con el que se abrió la sesión."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "Cuándo se abrió."
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "Cuándo caduca (time-box de 20 min)."
          }
        }
      },
      "Announcement": {
        "type": "object",
        "title": "Announcement",
        "description": "Anuncio de plataforma emitido por un platform admin (downtime, migraciones, deprecaciones). El fan-out reutiliza el dispatcher de notificaciones, así que respeta las preferencias y bajas de cada usuario. `recipient_count` es el alcance (usuarios elegibles) resuelto al enviar; `status` recorre draft → queued → sending → sent | failed.",
        "required": [
          "id",
          "title",
          "body",
          "audience_scope",
          "status",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Identificador del anuncio."
          },
          "title": {
            "type": "string",
            "description": "Título del anuncio."
          },
          "body": {
            "type": "string",
            "description": "Cuerpo del anuncio (texto)."
          },
          "audience_scope": {
            "type": "string",
            "description": "Segmentación de la audiencia.",
            "enum": [
              "global",
              "plan",
              "org"
            ]
          },
          "audience_plan": {
            "type": [
              "string",
              "null"
            ],
            "description": "Edición objetivo cuando `audience_scope=plan` (free|pro)."
          },
          "audience_org_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Organización objetivo cuando `audience_scope=org`."
          },
          "link": {
            "type": [
              "string",
              "null"
            ],
            "description": "Enlace opcional al que apunta la notificación (p. ej. una status page)."
          },
          "status": {
            "type": "string",
            "description": "Estado del ciclo de vida del envío.",
            "enum": [
              "draft",
              "queued",
              "sending",
              "sent",
              "failed"
            ]
          },
          "recipient_count": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Nº de usuarios elegibles a los que se abanicó (se rellena al terminar el envío); `null` mientras no se ha enviado."
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Platform admin que creó el anuncio (`null` si se borró después)."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "Cuándo se creó el anuncio."
          },
          "sent_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cuándo terminó el envío (`sent`); `null` mientras no ha terminado."
          }
        }
      },
      "AnnouncementCreate": {
        "type": "object",
        "title": "AnnouncementCreate",
        "description": "Alta de un anuncio de plataforma. Crea la fila y encola el job de fan-out sobre el módulo de notificaciones. La coherencia de la audiencia se valida en el servidor (plan exige `audience_plan`; org exige `audience_org_id`).",
        "required": [
          "title",
          "body",
          "audience_scope"
        ],
        "properties": {
          "title": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "Título del anuncio."
          },
          "body": {
            "type": "string",
            "minLength": 1,
            "description": "Cuerpo del anuncio (texto)."
          },
          "audience_scope": {
            "type": "string",
            "description": "Segmentación de la audiencia.",
            "enum": [
              "global",
              "plan",
              "org"
            ]
          },
          "audience_plan": {
            "type": [
              "string",
              "null"
            ],
            "description": "Plan objetivo (obligatorio si `audience_scope=plan`).",
            "enum": [
              "free",
              "equipo",
              "projekt",
              null
            ]
          },
          "audience_org_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Organización objetivo (obligatoria si `audience_scope=org`)."
          },
          "link": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 512,
            "description": "Enlace opcional al que apunta la notificación."
          }
        }
      },
      "AnnouncementRecipientCount": {
        "type": "object",
        "title": "AnnouncementRecipientCount",
        "description": "Recuento de usuarios elegibles para una audiencia dada, calculado sin enviar nada (preview de confirmación). Coincide con el `recipient_count` que quedará registrado al enviar el anuncio con la misma audiencia.",
        "required": [
          "count",
          "audience_scope"
        ],
        "properties": {
          "count": {
            "type": "integer",
            "description": "Nº de usuarios distintos elegibles para esta audiencia."
          },
          "audience_scope": {
            "type": "string",
            "description": "Segmentación evaluada.",
            "enum": [
              "global",
              "plan",
              "org"
            ]
          }
        }
      },
      "GlobalSupplier": {
        "type": "object",
        "title": "GlobalSupplier",
        "description": "Proveedor del catálogo global compartido de la plataforma.",
        "required": [
          "id",
          "name",
          "tax_id",
          "email",
          "phone",
          "address",
          "website",
          "logo_url",
          "notes",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "examples": [
              "Hetzner Online GmbH"
            ]
          },
          "tax_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "NIF/CIF/VAT del proveedor; clave de deduplicación preferente."
          },
          "email": {
            "type": [
              "string",
              "null"
            ]
          },
          "phone": {
            "type": [
              "string",
              "null"
            ]
          },
          "address": {
            "type": [
              "string",
              "null"
            ]
          },
          "website": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL pública del proveedor."
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "description": "Notas de curación del catálogo (visibles para todas las orgs)."
          },
          "linked_count": {
            "type": "integer",
            "description": "Número de proveedores org-scoped vinculados a esta entrada.",
            "default": 0
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "GlobalSupplierWrite": {
        "type": "object",
        "title": "GlobalSupplierWrite",
        "description": "Datos de creación o edición de un proveedor del catálogo global.",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string"
          },
          "tax_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "email": {
            "type": [
              "string",
              "null"
            ]
          },
          "phone": {
            "type": [
              "string",
              "null"
            ]
          },
          "address": {
            "type": [
              "string",
              "null"
            ]
          },
          "website": {
            "type": [
              "string",
              "null"
            ]
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "GlobalSupplierImportResult": {
        "type": "object",
        "title": "GlobalSupplierImportResult",
        "description": "Informe del import masivo de proveedores globales.",
        "required": [
          "created",
          "updated",
          "skipped",
          "errors"
        ],
        "properties": {
          "created": {
            "type": "integer",
            "description": "Filas que crearon una entrada nueva."
          },
          "updated": {
            "type": "integer",
            "description": "Filas que actualizaron una entrada existente (match por tax_id o nombre)."
          },
          "skipped": {
            "type": "integer",
            "description": "Filas ignoradas (duplicadas dentro del propio CSV o vacías)."
          },
          "errors": {
            "type": "array",
            "description": "Errores por fila (máx. 50), con número de línea y motivo.",
            "items": {
              "type": "object",
              "required": [
                "line",
                "reason"
              ],
              "properties": {
                "line": {
                  "type": "integer"
                },
                "reason": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "PlatformSubscriptionDetail": {
        "type": "object",
        "title": "PlatformSubscriptionDetail",
        "description": "Estado de licencia de una organización visto por un platform admin: el plan EFECTIVO (`organizations.plan`) y su origen (`plan_source` ∈ default|stripe|manual), el override manual si lo hay, el espejo de Stripe (tabla `subscriptions`) si existe, y `diverged` = el plan derivado del espejo de Stripe (su `plan` si la suscripción está activa; si no, `free`) NO coincide con el plan efectivo — típico de un override manual o de un webhook pendiente.",
        "required": [
          "organization_id",
          "organization_name",
          "effective_plan",
          "plan_source",
          "override",
          "stripe",
          "diverged"
        ],
        "properties": {
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_name": {
            "type": "string"
          },
          "effective_plan": {
            "type": "string",
            "description": "Plan EFECTIVO materializado que lee el gating (`organizations.plan`).",
            "enum": [
              "free",
              "equipo",
              "projekt"
            ]
          },
          "plan_source": {
            "type": "string",
            "description": "Origen del plan efectivo. `manual` = override otorgado a mano por ops.",
            "enum": [
              "default",
              "stripe",
              "manual"
            ]
          },
          "override": {
            "type": [
              "object",
              "null"
            ],
            "title": "PlatformSubscriptionOverride",
            "description": "Metadatos del override manual (plan otorgado a mano por ops). `null` si el plan efectivo NO viene de un override (`plan_source` ≠ `manual`).",
            "required": [
              "reason",
              "expires_at",
              "by",
              "at"
            ],
            "properties": {
              "reason": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Motivo que registró el admin al otorgar el plan."
              },
              "expires_at": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time",
                "description": "Cuándo caduca el override; `null` = permanente hasta revocarlo."
              },
              "by": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "uuid",
                "description": "Platform admin que otorgó el override; `null` si su cuenta se borró."
              },
              "at": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time",
                "description": "Cuándo se otorgó el override."
              }
            }
          },
          "stripe": {
            "type": [
              "object",
              "null"
            ],
            "title": "PlatformSubscriptionStripe",
            "description": "Espejo del estado de facturación en Stripe (tabla `subscriptions`). `null` si la organización nunca ha pasado por checkout.",
            "required": [
              "status",
              "plan",
              "current_period_end",
              "customer_id",
              "subscription_id"
            ],
            "properties": {
              "status": {
                "type": "string",
                "description": "Estado en Stripe (active, trialing, past_due, canceled, incomplete…)."
              },
              "plan": {
                "type": "string",
                "description": "Plan comprado en Stripe (el efectivo puede diferir si hay override)."
              },
              "current_period_end": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time",
                "description": "Fin del periodo en curso (renovación/expiración); `null` si aún no hay suscripción."
              },
              "customer_id": {
                "type": "string",
                "description": "Customer id de Stripe."
              },
              "subscription_id": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Subscription id de Stripe; `null` hasta completar el checkout."
              }
            }
          },
          "diverged": {
            "type": "boolean",
            "description": "El plan derivado del espejo de Stripe ≠ el plan efectivo."
          }
        }
      },
      "PlatformPlanGrant": {
        "type": "object",
        "title": "PlatformPlanGrant",
        "description": "Otorga un plan a mano a una organización (override manual, decisión D2): fija el plan EFECTIVO y `plan_source='manual'`, sin llamar a Stripe ni cobrar. Mientras el override siga activo, ningún webhook de Stripe lo pisa. `reason` es OBLIGATORIO (mín. 1 carácter; si falta o va vacío, 422 `validation_error`). Idempotente: re-otorgar actualiza el override existente.\n\nGUARDIA (F8, misma decisión D2e que el camino masivo): si la organización tiene una suscripción de Stripe VIVA (`active`/`trialing`/`past_due`), el override se rechaza con 409 `managed_by_stripe` — congelaría su plan mientras Stripe le sigue cobrando. Para asumirlo por escrito hay que mandar `force: true`.",
        "required": [
          "plan",
          "reason"
        ],
        "properties": {
          "plan": {
            "type": "string",
            "description": "Plan a otorgar.",
            "enum": [
              "free",
              "equipo",
              "projekt"
            ]
          },
          "reason": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255,
            "description": "Motivo del otorgamiento (obligatorio; queda en el audit y en el override)."
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cuándo caduca el override; omitir o `null` = permanente hasta revocarlo."
          },
          "force": {
            "type": "boolean",
            "default": false,
            "description": "Otorgar el plan AUNQUE la org tenga una suscripción de Stripe viva, asumiendo que se le seguirá cobrando en Stripe. Omitir o `false` = la guardia manda (409 `managed_by_stripe`). Queda en el audit (`payload.force`) y en el aviso al resto de admins."
          }
        }
      },
      "PlatformSubscriptionRow": {
        "type": "object",
        "title": "PlatformSubscriptionRow",
        "description": "Fila de la lista de flota de licencias: una organización con su plan efectivo, origen, estado del espejo de Stripe (si existe) y si diverge. Pensada para una tabla paginada; el detalle completo vive en el endpoint de subscription.",
        "required": [
          "organization_id",
          "organization_name",
          "effective_plan",
          "plan_source",
          "stripe_status",
          "diverged"
        ],
        "properties": {
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_name": {
            "type": "string"
          },
          "effective_plan": {
            "type": "string",
            "enum": [
              "free",
              "equipo",
              "projekt"
            ]
          },
          "plan_source": {
            "type": "string",
            "enum": [
              "default",
              "stripe",
              "manual"
            ]
          },
          "stripe_status": {
            "type": [
              "string",
              "null"
            ],
            "description": "Estado del espejo de Stripe; `null` si la org nunca ha pasado por checkout."
          },
          "diverged": {
            "type": "boolean",
            "description": "El plan derivado del espejo de Stripe ≠ el plan efectivo."
          }
        }
      },
      "PlatformSubscriptionCancel": {
        "type": "object",
        "title": "PlatformSubscriptionCancel",
        "description": "Opciones para cancelar la suscripción de Stripe de una organización desde el panel ops. `at_period_end=true` (defecto) programa la cancelación al final del periodo pagado (el plan sigue vigente hasta entonces); `false` cancela de inmediato.",
        "properties": {
          "at_period_end": {
            "type": "boolean",
            "default": true,
            "description": "`true` (defecto): cancelar al final del periodo en curso (Stripe `cancel_at_period_end`). `false`: cancelar de inmediato (Stripe elimina la suscripción y el plan efectivo cae a `free`, salvo override manual activo)."
          }
        }
      },
      "PlatformSubscriptionResyncResult": {
        "type": "object",
        "title": "PlatformSubscriptionResyncResult",
        "description": "Resultado de reconciliar una divergencia releyendo la suscripción REAL de Stripe (solo lectura en Stripe): plan efectivo antes y después, estado de la suscripción según Stripe y si la conciliación cambió algo. La aplicación del estado reutiliza la misma lógica que el webhook de Stripe.",
        "required": [
          "before_plan",
          "after_plan",
          "stripe_status",
          "changed"
        ],
        "properties": {
          "before_plan": {
            "type": "string",
            "enum": [
              "free",
              "equipo",
              "projekt"
            ],
            "description": "Plan efectivo de la organización ANTES de conciliar."
          },
          "after_plan": {
            "type": "string",
            "enum": [
              "free",
              "equipo",
              "projekt"
            ],
            "description": "Plan efectivo DESPUÉS de conciliar. Puede seguir divergiendo del espejo si hay un override manual activo (el override manda, decisión D2)."
          },
          "stripe_status": {
            "type": "string",
            "description": "Estado de la suscripción tal y como lo devuelve Stripe (active, past_due…)."
          },
          "changed": {
            "type": "boolean",
            "description": "`true` si el plan efectivo cambió (`before_plan != after_plan`)."
          }
        }
      },
      "PlatformRevenueSummary": {
        "type": "object",
        "title": "PlatformRevenueSummary",
        "description": "Agregado de ingresos recurrentes de toda la plataforma, computado EN VIVO OFF `subscriptions` (filas con suscripción de Stripe real en un estado que cuenta: `active` o `past_due` — `trialing`/`canceled` no cuentan), NUNCA off `organizations.plan` (hay orgs con Pro concedido a mano, sin Stripe detrás). Moneda única: las subs en otra divisa se segregan y no se suman. Los campos de movimiento (`mrr_change_pct`, `new_mrr_minor`, `churned_mrr_minor`) son tendencia derivada de los dos snapshots diarios más recientes (`mrr_daily_snapshots`), no del número en vivo.\n\nCOBERTURA (F6): `mrr_minor` solo puede sumar las suscripciones cuyas columnas de dinero ya se releyeron de Stripe. `synced_subscriptions` dice CUÁNTAS de las `active_subscriptions` aportan importe: si es menor, el MRR está INCOMPLETO y `basis` vale `partial`. La UI debe declararlo («38 de 40 sincronizadas»), nunca presentar un agregado a medias como definitivo.",
        "required": [
          "mrr_minor",
          "arr_minor",
          "currency",
          "active_subscriptions",
          "synced_subscriptions",
          "last_synced_at",
          "arpa_minor",
          "mrr_change_pct",
          "new_mrr_minor",
          "churned_mrr_minor",
          "as_of",
          "basis",
          "stripe_configured"
        ],
        "properties": {
          "mrr_minor": {
            "type": "integer",
            "format": "int64",
            "description": "MRR total en minor units (céntimos) de la divisa base."
          },
          "arr_minor": {
            "type": "integer",
            "format": "int64",
            "description": "ARR = MRR × 12, en minor units."
          },
          "currency": {
            "type": "string",
            "description": "Divisa base ISO 4217 (minúsculas, p.ej. `usd`) del agregado."
          },
          "active_subscriptions": {
            "type": "integer",
            "description": "Suscripciones que cuentan (active/past_due) en la divisa base."
          },
          "synced_subscriptions": {
            "type": "integer",
            "description": "Cuántas de esas suscripciones tienen ya el importe releído de Stripe y por tanto aportan a `mrr_minor`. Igual a `active_subscriptions` ⇒ el MRR está completo; menor ⇒ está incompleto (`basis` = `partial`)."
          },
          "last_synced_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Última vez que se releyó de Stripe alguna de las suscripciones que cuentan; `null` si ninguna se ha sincronizado nunca. Es la antigüedad real del MRR."
          },
          "arpa_minor": {
            "type": "integer",
            "format": "int64",
            "description": "ARPA = MRR / suscripciones SINCRONIZADAS (minor units, división entera). El denominador excluye las no sincronizadas a propósito: aportan 0 al numerador, así que incluirlas diluiría el ARPA (F6). 0 si no hay ninguna sincronizada (nunca hay división por cero)."
          },
          "mrr_change_pct": {
            "type": [
              "number",
              "null"
            ],
            "description": "Variación porcentual del MRR entre los dos snapshots diarios más recientes; `null` si hay menos de 2 snapshots o el previo es 0."
          },
          "new_mrr_minor": {
            "type": "integer",
            "format": "int64",
            "description": "Expansión NETA de MRR (minor units) desde el snapshot previo al último; 0 si no hay 2 snapshots."
          },
          "churned_mrr_minor": {
            "type": "integer",
            "format": "int64",
            "description": "Contracción NETA de MRR (minor units) desde el snapshot previo al último; 0 si no hay 2 snapshots."
          },
          "as_of": {
            "type": "string",
            "format": "date-time",
            "description": "Instante (UTC) en que se computó el agregado en vivo."
          },
          "basis": {
            "type": "string",
            "enum": [
              "stripe_synced",
              "partial",
              "list_price_estimate"
            ],
            "description": "Cuánto se puede fiar uno del MRR. `stripe_synced`: TODAS las suscripciones que cuentan tienen importe real de Stripe. `partial`: solo algunas (`synced_subscriptions` < `active_subscriptions`) — el MRR es un suelo, no la cifra real. `list_price_estimate`: ninguna (el número aún no es verdad de Stripe). El valor `partial` se añadió con F6; un cliente que solo conozca los otros dos debe tratarlo como «no fiable», nunca como `stripe_synced`."
          },
          "stripe_configured": {
            "type": "boolean",
            "description": "Si el backend tiene los pagos de Stripe habilitados + secret key."
          }
        }
      },
      "PlatformRevenueByPlanRow": {
        "type": "object",
        "title": "PlatformRevenueByPlanRow",
        "description": "MRR agregado por plan comprado, computado OFF `subscriptions` en un estado que cuenta (active/past_due) y en la divisa base. Invariante: la suma de `mrr_minor` de todas las filas == `PlatformRevenueSummary.mrr_minor`.",
        "required": [
          "plan",
          "mrr_minor",
          "active_subscriptions"
        ],
        "properties": {
          "plan": {
            "type": "string",
            "enum": [
              "free",
              "equipo",
              "projekt"
            ]
          },
          "mrr_minor": {
            "type": "integer",
            "format": "int64",
            "description": "MRR del plan en minor units, divisa base."
          },
          "active_subscriptions": {
            "type": "integer",
            "description": "Suscripciones que cuentan de este plan."
          }
        }
      },
      "PlatformRevenuePoint": {
        "type": "object",
        "title": "PlatformRevenuePoint",
        "description": "Valor de la métrica de revenue pedida para un día natural (UTC). Los huecos se rellenan por carry-forward del último valor conocido (0 antes del primer snapshot). La serie sale más antiguos primero.",
        "required": [
          "date",
          "value_minor"
        ],
        "properties": {
          "date": {
            "type": "string",
            "format": "date",
            "examples": [
              "2026-07-24"
            ]
          },
          "value_minor": {
            "type": "integer",
            "format": "int64",
            "description": "Métrica pedida (MRR o ARR/revenue) en minor units ese día."
          }
        }
      },
      "PlatformRevenueSubscriptionRow": {
        "type": "object",
        "title": "PlatformRevenueSubscriptionRow",
        "description": "Suscripción de Stripe (espejo local) con su MRR precomputado, para la tabla de revenue del panel ops. `synced` indica si las columnas de dinero (W10) están pobladas; `mrr_minor`/`currency` son `null` mientras no se hayan sincronizado desde Stripe.",
        "required": [
          "organization_id",
          "organization_name",
          "plan",
          "status",
          "mrr_minor",
          "currency",
          "synced",
          "current_period_end",
          "canceled_at"
        ],
        "properties": {
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_name": {
            "type": "string"
          },
          "plan": {
            "type": "string",
            "enum": [
              "free",
              "equipo",
              "projekt"
            ]
          },
          "status": {
            "type": "string",
            "description": "Estado del espejo de Stripe (active, past_due, trialing, canceled…)."
          },
          "mrr_minor": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "description": "MRR mensual en minor units; `null` si aún no se ha sincronizado."
          },
          "currency": {
            "type": [
              "string",
              "null"
            ],
            "description": "Divisa ISO 4217 (minúsculas); `null` si aún no se ha sincronizado."
          },
          "synced": {
            "type": "boolean",
            "description": "Las columnas de dinero (W10) están pobladas (MRR real de Stripe)."
          },
          "current_period_end": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Fin del periodo en curso; `null` si no hay suscripción todavía."
          },
          "canceled_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Fecha de cancelación; `null` si la suscripción sigue vigente."
          }
        }
      },
      "PlatformBillingAtRiskRow": {
        "type": "object",
        "title": "PlatformBillingAtRiskRow",
        "description": "Suscripción de Stripe con el cobro en riesgo (`past_due`, `unpaid` o `incomplete`): la organización, el email del owner (destinatario del aviso de pago), el plan comprado y desde cuándo está en riesgo. Sale del espejo local `subscriptions` — nunca se llama a Stripe en el read path.",
        "required": [
          "organization_id",
          "organization_name",
          "owner_email",
          "plan",
          "stripe_status",
          "since",
          "stripe_customer_id"
        ],
        "properties": {
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_name": {
            "type": "string"
          },
          "owner_email": {
            "type": [
              "string",
              "null"
            ],
            "description": "Email del owner de la organización (destinatario del aviso de pago). `null` solo en el estado anómalo de una org sin owner — el notify responde 422 (`owner_not_found`) en ese caso."
          },
          "plan": {
            "type": "string",
            "description": "Plan comprado según el espejo de Stripe (no el efectivo)."
          },
          "stripe_status": {
            "type": "string",
            "enum": [
              "past_due",
              "unpaid",
              "incomplete"
            ]
          },
          "since": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Mejor aproximación conocida del comienzo del riesgo: el fin del periodo pagado (`current_period_end`) y, si el checkout nunca se completó (`incomplete` sin periodo), el alta del espejo. `null` si no hay ninguna fecha disponible."
          },
          "stripe_customer_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Cliente de Stripe (para el enlace directo al dashboard de Stripe); `null` si el espejo no lo tiene."
          }
        }
      },
      "PlatformBillingAtRiskNotifyResult": {
        "type": "object",
        "title": "PlatformBillingAtRiskNotifyResult",
        "description": "Confirmación del reenvío del aviso de pago pendiente. `sent=false` significa que el anti-spam de 24 h lo frenó (ya se avisó a esa organización hace menos de un día); no es un error.",
        "required": [
          "sent"
        ],
        "properties": {
          "sent": {
            "type": "boolean",
            "description": "true si el aviso se ha encolado para envío por email; false si el anti-spam de 24 h lo ha omitido."
          }
        }
      },
      "PlatformModeloEdicionRow": {
        "type": "object",
        "title": "PlatformModeloEdicionRow",
        "description": "Una edición del modelo económico, con su gente y su rendimiento.",
        "required": [
          "edicion",
          "organizaciones",
          "personas",
          "organizaciones_de_pago",
          "personas_de_pago",
          "mrr_minor",
          "suscripciones_sin_importe",
          "ingreso_por_persona_minor"
        ],
        "properties": {
          "edicion": {
            "type": "string",
            "enum": [
              "free",
              "equipo",
              "projekt"
            ]
          },
          "organizaciones": {
            "type": "integer",
            "description": "Organizaciones con esta edición, de pago o no."
          },
          "personas": {
            "type": "integer",
            "description": "Personas (membresías) en esas organizaciones. Es el denominador del precio por persona, así que es la cifra que dice si la tarifa cuadra."
          },
          "organizaciones_de_pago": {
            "type": "integer",
            "description": "De las de arriba, las que tienen una suscripción que cuenta para MRR."
          },
          "personas_de_pago": {
            "type": "integer",
            "description": "Personas en las organizaciones DE PAGO. Se cuenta aparte porque es el denominador honesto del ingreso por persona: dividir el MRR entre toda la gente de la edición metería en la cuenta a quien no paga."
          },
          "mrr_minor": {
            "type": "integer",
            "description": "MRR de esta edición, en unidades menores de la divisa base."
          },
          "suscripciones_sin_importe": {
            "type": "integer",
            "description": "Suscripciones que cuentan pero cuyo importe todavía no ha sincronizado Stripe. Se dice para que un MRR bajo no se lea como «no facturan», sino como «faltan N por sincronizar»."
          },
          "ingreso_por_persona_minor": {
            "oneOf": [
              {
                "type": "integer"
              },
              {
                "type": "null"
              }
            ],
            "description": "MRR ÷ personas de pago. `null` cuando no hay a quién dividir — no 0, que se leería como «rinde cero» en vez de «no hay nadie pagando aquí»."
          }
        }
      },
      "PlatformModeloMetrics": {
        "type": "object",
        "title": "PlatformModeloMetrics",
        "description": "El reparto de las tres ediciones, con su gente y su rendimiento.",
        "required": [
          "moneda",
          "por_edicion",
          "organizaciones_totales",
          "personas_totales"
        ],
        "properties": {
          "moneda": {
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "description": "Divisa base del agregado. `null` si ninguna suscripción que cuenta tiene divisa sincronizada todavía; entonces los importes van a 0 y hay que mirar `suscripciones_sin_importe` antes de sacar conclusiones."
          },
          "por_edicion": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlatformModeloEdicionRow"
            },
            "description": "Una fila por edición, SIEMPRE las tres y en el orden de la escalera (Free → Equipo → Negocio), aunque alguna esté a cero: una edición que desaparece de la tabla porque nadie la tiene es justo el dato que había que ver."
          },
          "organizaciones_totales": {
            "type": "integer"
          },
          "personas_totales": {
            "type": "integer"
          }
        }
      },
      "PlatformRevenueMetrics": {
        "type": "object",
        "title": "PlatformRevenueMetrics",
        "description": "Serie MENSUAL (últimos 12 meses como máximo) de MRR, altas de pago y churn, más el desglose ACTUAL por plan. La serie solo cubre meses con evidencia real de datos (primer snapshot de MRR o primera suscripción registrada): si no hay histórico fiable para un mes, la serie empieza donde lo haya — vacía si no hay ninguno. Ver las limitaciones por campo en `PlatformRevenueMonthPoint` (churn derivado del estado actual, sin histórico completo de eventos).",
        "required": [
          "months",
          "by_plan"
        ],
        "properties": {
          "months": {
            "type": "array",
            "description": "Un punto por mes natural, más antiguos primero (≤ 12).",
            "items": {
              "$ref": "#/components/schemas/PlatformRevenueMonthPoint"
            }
          },
          "by_plan": {
            "type": "array",
            "description": "Desglose actual por plan (orden por MRR descendente).",
            "items": {
              "$ref": "#/components/schemas/PlatformRevenueMetricsByPlanRow"
            }
          }
        }
      },
      "PlatformRevenueMonthPoint": {
        "type": "object",
        "title": "PlatformRevenueMonthPoint",
        "description": "Métricas de revenue de UN mes natural (UTC). LIMITACIONES de los datos, sin histórico completo de eventos: `new_paid_orgs` se deriva del `created_at` de la fila espejo actual de `subscriptions` (una por org) — si la org se borra, su alta desaparece del histórico; `churned_orgs` se deriva del `canceled_at` del estado ACTUAL — una org que cancela y luego reactiva deja de contar como churn. `mrr_minor` de meses pasados es el último snapshot diario del mes (`mrr_daily_snapshots`, carry-forward del último conocido) y es `null` en los meses anteriores al primer snapshot (no se inventa un 0); el mes en curso se computa EN VIVO off `subscriptions`, mismo criterio que el resumen.",
        "required": [
          "month",
          "mrr_minor",
          "new_paid_orgs",
          "churned_orgs"
        ],
        "properties": {
          "month": {
            "type": "string",
            "pattern": "^\\d{4}-\\d{2}$",
            "description": "Mes natural en formato `YYYY-MM` (UTC).",
            "examples": [
              "2026-08"
            ]
          },
          "mrr_minor": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "description": "MRR a fin de mes en minor units (último snapshot diario del mes, carry-forward). `null` si el mes es anterior al primer snapshot. El mes en curso es el agregado en vivo (siempre entero)."
          },
          "new_paid_orgs": {
            "type": "integer",
            "description": "Orgs cuya suscripción de pago (espejo de Stripe con suscripción real y estado con evidencia de pago) se creó ese mes."
          },
          "churned_orgs": {
            "type": "integer",
            "description": "Orgs cuya suscripción se canceló ese mes (`canceled_at`)."
          }
        }
      },
      "PlatformRevenueMetricsByPlanRow": {
        "type": "object",
        "title": "PlatformRevenueMetricsByPlanRow",
        "description": "Estado ACTUAL por plan comprado, computado OFF `subscriptions` en un estado que cuenta (active/past_due) y en la divisa base — NUNCA off `organizations.plan` (hay orgs con Pro concedido a mano, sin Stripe detrás, que aquí no aparecen). Como hay una suscripción por org, `active_orgs` == suscripciones que cuentan.",
        "required": [
          "plan",
          "active_orgs",
          "mrr_minor"
        ],
        "properties": {
          "plan": {
            "type": "string",
            "enum": [
              "free",
              "equipo",
              "projekt"
            ]
          },
          "active_orgs": {
            "type": "integer",
            "description": "Orgs con suscripción que cuenta (active/past_due) en este plan."
          },
          "mrr_minor": {
            "type": "integer",
            "format": "int64",
            "description": "MRR del plan en minor units, divisa base."
          }
        }
      },
      "DaySchedule": {
        "oneOf": [
          {
            "type": "null"
          },
          {
            "type": "object",
            "required": [
              "start",
              "end"
            ],
            "properties": {
              "start": {
                "type": "string",
                "pattern": "^\\d{2}:\\d{2}$",
                "examples": [
                  "09:00"
                ]
              },
              "end": {
                "type": "string",
                "pattern": "^\\d{2}:\\d{2}$",
                "examples": [
                  "17:00"
                ]
              }
            }
          }
        ]
      },
      "notification_channel_limit": {
        "type": "object",
        "title": "NotificationChannelLimit",
        "description": "Un evento cuyo reparto está ACOTADO por código: por muy activada que tenga el usuario la preferencia de su categoría, este evento solo puede salir por los canales que se listan en `channels`. Existe porque la preferencia se guarda por CATEGORÍA y hay eventos que no admiten todos los canales de la suya —una llamada instantánea por correo llega cuando ya has colgado—. Sin este dato la pantalla de preferencias ofrece casillas que no hacen nada.",
        "required": [
          "event",
          "label",
          "channels"
        ],
        "properties": {
          "event": {
            "type": "string",
            "description": "Clave del tipo de evento (`Notification.type`).",
            "example": "meeting_call"
          },
          "label": {
            "type": "string",
            "description": "Etiqueta legible del evento (ES).",
            "example": "Te están llamando"
          },
          "channels": {
            "type": "array",
            "description": "Los ÚNICOS canales por los que este evento puede salir. Lo que no está aquí no se enviará nunca, aunque la preferencia de la categoría lo active.",
            "items": {
              "type": "string",
              "enum": [
                "in_app",
                "email",
                "web_push"
              ]
            },
            "example": [
              "in_app",
              "web_push"
            ]
          }
        }
      },
      "notification_preference": {
        "type": "object",
        "title": "NotificationPreference",
        "description": "Preferencia efectiva del usuario para una categoría de notificaciones. Modelo opt-out: si el usuario no ha ajustado la categoría, sale con todo activado (in_app+email+web_push, digest `instant`, sin horas de silencio).",
        "required": [
          "category",
          "in_app",
          "email",
          "web_push",
          "digest",
          "quiet_hours_start",
          "quiet_hours_end",
          "available_channels",
          "channel_limits"
        ],
        "properties": {
          "category": {
            "type": "string",
            "description": "Categoría del registro de eventos.",
            "enum": [
              "tasks",
              "comments",
              "finance",
              "hr",
              "projects",
              "crossorg",
              "org",
              "automation",
              "system",
              "chat",
              "crm",
              "billing",
              "sla",
              "reminders"
            ]
          },
          "in_app": {
            "type": "boolean",
            "description": "Recibir en la campana in-app."
          },
          "email": {
            "type": "boolean",
            "description": "Recibir por email."
          },
          "web_push": {
            "type": "boolean",
            "description": "Recibir por Web Push (desktop/PWA)."
          },
          "digest": {
            "type": "string",
            "description": "Agregación temporal del email/push (`instant` = inmediato).",
            "enum": [
              "instant",
              "daily",
              "weekly",
              "off"
            ]
          },
          "quiet_hours_start": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 23,
            "description": "Hora (0-23) de inicio del silencio; `null` = sin silencio."
          },
          "quiet_hours_end": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 23,
            "description": "Hora (0-23) de fin del silencio; `null` = sin silencio."
          },
          "available_channels": {
            "type": "array",
            "description": "Canales por los que ALGÚN aviso de esta categoría puede salir. Un canal que falta aquí está muerto para la categoría entera: ningún evento suyo lo usa nunca, así que su interruptor no debe ofrecerse activable. Es la unión de los techos de sus eventos; los que no tienen techo aportan los tres.",
            "items": {
              "type": "string",
              "enum": [
                "in_app",
                "email",
                "web_push"
              ]
            }
          },
          "channel_limits": {
            "type": "array",
            "description": "Eventos de ESTA categoría cuyo reparto está acotado por código y que, por tanto, ignoran parte de los interruptores de arriba. Vacío = todos los eventos de la categoría respetan los tres canales. El cliente debe decirlo: un interruptor que el servidor no puede cumplir es una casilla que miente.",
            "items": {
              "$ref": "#/components/schemas/notification_channel_limit"
            }
          }
        }
      },
      "notification_preference_in": {
        "type": "object",
        "title": "NotificationPreferenceIn",
        "description": "Ajuste (upsert) de la preferencia de una categoría del usuario actual. Solo `category` es obligatoria; el resto usa el default opt-out si se omite.",
        "required": [
          "category"
        ],
        "properties": {
          "category": {
            "type": "string",
            "enum": [
              "tasks",
              "comments",
              "finance",
              "hr",
              "projects",
              "crossorg",
              "org",
              "automation",
              "system",
              "chat",
              "crm",
              "billing",
              "sla",
              "reminders"
            ]
          },
          "in_app": {
            "type": "boolean",
            "default": true
          },
          "email": {
            "type": "boolean",
            "default": true
          },
          "web_push": {
            "type": "boolean",
            "default": true
          },
          "digest": {
            "type": "string",
            "enum": [
              "instant",
              "daily",
              "weekly",
              "off"
            ],
            "default": "instant"
          },
          "quiet_hours_start": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 23
          },
          "quiet_hours_end": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 23
          }
        }
      },
      "push_subscription_in": {
        "type": "object",
        "title": "PushSubscriptionIn",
        "required": [
          "endpoint",
          "keys"
        ],
        "properties": {
          "endpoint": {
            "type": "string",
            "description": "Endpoint push del navegador."
          },
          "keys": {
            "type": "object",
            "required": [
              "p256dh",
              "auth"
            ],
            "properties": {
              "p256dh": {
                "type": "string"
              },
              "auth": {
                "type": "string"
              }
            }
          },
          "user_agent": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "push_subscription": {
        "type": "object",
        "title": "PushSubscription",
        "required": [
          "endpoint",
          "created_at"
        ],
        "properties": {
          "endpoint": {
            "type": "string",
            "description": "Endpoint push del navegador (URL del servicio de push)."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "push_subscription_delete_in": {
        "type": "object",
        "title": "PushSubscriptionDeleteIn",
        "required": [
          "endpoint"
        ],
        "properties": {
          "endpoint": {
            "type": "string"
          }
        }
      },
      "device_token_in": {
        "type": "object",
        "title": "DeviceTokenIn",
        "required": [
          "platform",
          "token"
        ],
        "properties": {
          "platform": {
            "type": "string",
            "enum": [
              "apns",
              "fcm"
            ]
          },
          "token": {
            "type": "string",
            "description": "Token de dispositivo de APNs (Apple) o FCM (Firebase/Android)."
          }
        }
      },
      "device_token": {
        "type": "object",
        "title": "DeviceToken",
        "required": [
          "platform",
          "created_at"
        ],
        "properties": {
          "platform": {
            "type": "string",
            "description": "apns | fcm."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "device_token_delete_in": {
        "type": "object",
        "title": "DeviceTokenDeleteIn",
        "required": [
          "token"
        ],
        "properties": {
          "token": {
            "type": "string"
          }
        }
      },
      "chat_channel": {
        "type": "object",
        "title": "ChatChannel",
        "required": [
          "id",
          "type",
          "name",
          "project_id",
          "is_archived",
          "unread",
          "is_muted",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "type": {
            "type": "string",
            "enum": [
              "dm",
              "group",
              "project",
              "channel"
            ]
          },
          "name": {
            "type": [
              "string",
              "null"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 500,
            "description": "Descripción/propósito del canal (PJKT-1957). null en DM."
          },
          "icon_url": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 512,
            "description": "Icono/logo del canal (grupo/proyecto). null → iniciales del nombre. Los DM lo ignoran."
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "is_public": {
            "type": "boolean",
            "description": "Solo type=channel: true = legible/escribible por cualquier miembro de la org. Siempre false en dm/group/project."
          },
          "voice_enabled": {
            "type": "boolean",
            "description": "Solo type=channel: el canal tiene una sala de audio permanente a la que se entra y se sale (estilo Discord). Siempre false en dm/group/project."
          },
          "is_cross_org": {
            "type": "boolean",
            "description": "El canal es un directo ENTRE ORGANIZACIONES (punto 14): sus dos participantes están en organizaciones distintas y cada uno lo ve desde la suya. Siempre false en el resto. La UI lo usa para marcar la conversación como externa — quien escribe tiene que saber que está hablando fuera de casa."
          },
          "peer_organization_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "DM externo: nombre de la organización del otro participante. null en los directos internos, que son de la misma organización y no hay nada que aclarar."
          },
          "is_member": {
            "type": "boolean",
            "description": "¿El usuario actual es miembro del canal? Relevante al explorar canales públicos (browse); en «mis canales» siempre true."
          },
          "member_count": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Nº de miembros del canal (solo se calcula en browse/detalle; null si no se calculó)."
          },
          "is_archived": {
            "type": "boolean"
          },
          "unread": {
            "type": "integer",
            "description": "No-leídos (F1.4; de momento 0)."
          },
          "is_muted": {
            "type": "boolean",
            "description": "Silenciado por el usuario actual (F1.8)."
          },
          "peer_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "DM: id del otro participante (por-viewer). null en group/project."
          },
          "peer_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "DM: nombre del otro participante."
          },
          "peer_avatar_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "DM: avatar del otro participante."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "chat_channel_create_in": {
        "type": "object",
        "title": "ChatChannelCreateIn",
        "required": [
          "type"
        ],
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "dm",
              "group",
              "project",
              "channel"
            ]
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 500,
            "description": "Descripción/propósito (solo canales no-DM)."
          },
          "is_public": {
            "type": "boolean",
            "description": "Solo type=channel: true = abierto a toda la org (false si se omite). En otros tipos → 422."
          },
          "voice_enabled": {
            "type": "boolean",
            "description": "Solo type=channel: crea el canal con sala de audio permanente (false si se omite). En otros tipos → 422."
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "member_ids": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid"
            }
          }
        }
      },
      "chat_channel_member": {
        "type": "object",
        "title": "ChatChannelMember",
        "required": [
          "user_id",
          "role",
          "joined_at"
        ],
        "properties": {
          "user_id": {
            "type": "string",
            "format": "uuid"
          },
          "role": {
            "type": "string",
            "description": "owner | moderator | member (moderator solo en type=channel, PJKT-1957)"
          },
          "joined_at": {
            "type": "string",
            "format": "date-time"
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nombre del usuario."
          },
          "avatar_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Avatar del usuario (null = iniciales)."
          }
        }
      },
      "chat_channel_detail": {
        "title": "ChatChannelDetail",
        "allOf": [
          {
            "$ref": "#/components/schemas/chat_channel"
          },
          {
            "type": "object",
            "required": [
              "members"
            ],
            "properties": {
              "members": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/chat_channel_member"
                }
              }
            }
          }
        ]
      },
      "chat_channel_update_in": {
        "type": "object",
        "title": "ChatChannelUpdateIn",
        "description": "Cambia el nombre y/o el icono de un canal de grupo o de proyecto. Ambos campos son opcionales; se aplican solo los presentes (enviar `icon_url: null` limpia el icono). Los DM no admiten edición.",
        "properties": {
          "name": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 500,
            "description": "Descripción/propósito del canal. null para limpiarla."
          },
          "icon_url": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 512,
            "description": "URL del icono/logo del canal. null o vacío para quitarlo."
          },
          "is_public": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Solo type=channel: cambia la visibilidad público/privado. En otros tipos → 422. null/ausente = sin cambio."
          },
          "voice_enabled": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Solo type=channel: enciende/apaga la sala de audio permanente del canal. En otros tipos → 422. null/ausente = sin cambio. Apagarlo vacía el censo de la sala."
          }
        }
      },
      "chat_add_members_in": {
        "type": "object",
        "title": "ChatAddMembersIn",
        "required": [
          "member_ids"
        ],
        "properties": {
          "member_ids": {
            "type": "array",
            "minItems": 1,
            "items": {
              "type": "string",
              "format": "uuid"
            }
          }
        }
      },
      "chat_member_role_in": {
        "type": "object",
        "title": "ChatMemberRoleIn",
        "required": [
          "role"
        ],
        "properties": {
          "role": {
            "type": "string",
            "enum": [
              "moderator",
              "member"
            ]
          }
        }
      },
      "chat_message_parent_preview": {
        "title": "ChatMessageParentPreview",
        "type": "object",
        "description": "Extracto del mensaje citado (reply), para pintar la cita sin fetch extra.",
        "required": [
          "author_id",
          "author_name",
          "body_excerpt"
        ],
        "properties": {
          "author_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "author_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "body_excerpt": {
            "type": "string",
            "description": "Primeros ~80 caracteres del mensaje citado."
          }
        }
      },
      "chat_reaction": {
        "type": "object",
        "title": "ChatReaction",
        "required": [
          "emoji",
          "count",
          "mine"
        ],
        "properties": {
          "emoji": {
            "type": "string"
          },
          "count": {
            "type": "integer"
          },
          "mine": {
            "type": "boolean"
          }
        }
      },
      "chat_attachment": {
        "type": "object",
        "title": "ChatAttachment",
        "required": [
          "id",
          "filename",
          "mime",
          "size",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "filename": {
            "type": "string"
          },
          "mime": {
            "type": "string"
          },
          "size": {
            "type": "integer"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "chat_message": {
        "type": "object",
        "title": "ChatMessage",
        "required": [
          "id",
          "channel_id",
          "author_id",
          "kind",
          "entity_type",
          "entity_id",
          "body",
          "parent_id",
          "is_edited",
          "edited_at",
          "created_at",
          "reactions",
          "attachments"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "channel_id": {
            "type": "string",
            "format": "uuid"
          },
          "author_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "kind": {
            "type": "string",
            "enum": [
              "user",
              "system"
            ],
            "description": "Quién escribió esto: una persona (`user`) o el producto (`system`).\n\nNo se deduce de `author_id: null`, que ya significaba otra cosa —«el autor se dio de baja»—. Un mensaje `system` no se edita y no se borra: no tiene autor que lo edite y el owner del canal tampoco lo borra; los dos intentos dan 422 con `system_message_immutable`.\n\nLo que SÍ hace es contar como no leído, y para todos los miembros del canal — incluido quien provocó el aviso, que ve su propia tarjeta con punto hasta que la mira. Es deliberado: un aviso que nace leído no se ve, y estos mensajes existen precisamente para que mañana haya dónde volver a encontrar aquello de lo que hablan. Hoy los emite la invitación a una reunión."
          },
          "entity_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "De qué habla un mensaje de sistema (`meeting`). `null` en los normales. Con `entity_id`, es lo que deja pintarlo como tarjeta con su botón en vez de como texto plano con una URL dentro."
          },
          "entity_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "body": {
            "type": "string"
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "parent_preview": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/chat_message_parent_preview"
              },
              {
                "type": "null"
              }
            ],
            "description": "Extracto del mensaje citado (reply). null si no es respuesta."
          },
          "is_edited": {
            "type": "boolean"
          },
          "edited_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "pinned_until": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Fijado hasta esta fecha (UTC), o null si no está pineado / el pin caducó. «Siempre» = fecha muy lejana (año 9999)."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "reactions": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/chat_reaction"
            }
          },
          "attachments": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/chat_attachment"
            }
          }
        }
      },
      "chat_message_create_in": {
        "type": "object",
        "title": "ChatMessageCreateIn",
        "required": [
          "body"
        ],
        "properties": {
          "body": {
            "type": "string",
            "minLength": 1,
            "maxLength": 16000
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Mensaje raíz del hilo."
          }
        }
      },
      "chat_message_edit_in": {
        "type": "object",
        "title": "ChatMessageEditIn",
        "required": [
          "body"
        ],
        "properties": {
          "body": {
            "type": "string",
            "minLength": 1,
            "maxLength": 16000
          }
        }
      },
      "chat_voice_participant": {
        "type": "object",
        "title": "ChatVoiceParticipant",
        "required": [
          "user_id",
          "joined_at"
        ],
        "properties": {
          "user_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nombre del usuario (null si la cuenta ya no existe)."
          },
          "avatar_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "joined_at": {
            "type": "string",
            "format": "date-time",
            "description": "Cuándo entró a la sala (NO cuándo latió por última vez). Se conserva mientras siga dentro; si sale y vuelve, se reinicia."
          }
        }
      },
      "chat_voice_channel": {
        "type": "object",
        "title": "ChatVoiceChannel",
        "required": [
          "channel_id",
          "name",
          "is_public",
          "is_member",
          "is_archived",
          "room",
          "participants",
          "participant_count"
        ],
        "properties": {
          "channel_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": [
              "string",
              "null"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 500
          },
          "is_public": {
            "type": "boolean",
            "description": "Abierto a toda la org: cualquier miembro puede entrar al audio."
          },
          "is_member": {
            "type": "boolean",
            "description": "¿El usuario actual es miembro del canal?"
          },
          "is_archived": {
            "type": "boolean"
          },
          "room": {
            "type": "string",
            "description": "Sala LiveKit permanente del canal."
          },
          "participants": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/chat_voice_participant"
            }
          },
          "participant_count": {
            "type": "integer",
            "minimum": 0
          }
        }
      },
      "chat_voice_room": {
        "type": "object",
        "title": "ChatVoiceRoom",
        "required": [
          "channel_id",
          "room",
          "participants",
          "participant_count"
        ],
        "properties": {
          "channel_id": {
            "type": "string",
            "format": "uuid"
          },
          "room": {
            "type": "string",
            "description": "Nombre de la sala LiveKit del canal. Es PERMANENTE y derivado del canal (`voice_<channel_id>`): no se crea ni se destruye nada al entrar o salir."
          },
          "participants": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/chat_voice_participant"
            }
          },
          "participant_count": {
            "type": "integer",
            "minimum": 0
          }
        }
      },
      "chat_voice_join": {
        "type": "object",
        "title": "ChatVoiceJoin",
        "required": [
          "token",
          "url",
          "room",
          "identity",
          "participants",
          "participant_count"
        ],
        "properties": {
          "token": {
            "type": "string",
            "description": "JWT de acceso LiveKit (HS256)."
          },
          "url": {
            "type": "string",
            "description": "URL wss:// pública del servidor LiveKit."
          },
          "room": {
            "type": "string"
          },
          "identity": {
            "type": "string",
            "description": "Identidad de ESTA conexión dentro de la sala LiveKit: `<user_id>#<sufijo>`. NO es el `user_id` a secas, y desde el 31/08/2026 tampoco un UUID. LiveKit expulsa al participante anterior cuando entra otro con la misma identidad, así que dos clientes de la misma persona —la ventana del escritorio y la pestaña del enlace— se echaban mutuamente en bucle. El `user_id` sigue delante del `#` para quien tenga que ir de participante a usuario; el nombre que se pinta va en el claim `name` del token."
          },
          "participants": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/chat_voice_participant"
            }
          },
          "participant_count": {
            "type": "integer",
            "minimum": 0
          }
        }
      },
      "chat_external_person": {
        "type": "object",
        "title": "ChatExternalPerson",
        "required": [
          "user_id",
          "name",
          "organization_id",
          "organization_name"
        ],
        "properties": {
          "user_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "examples": [
              "Ada Lovelace"
            ]
          },
          "avatar_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Avatar de la persona; `null` si no tiene."
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "description": "Organización POR LA QUE es alcanzable. Una persona puede estar en varias: sale una fila por cada organización suya que tenga la puerta abierta, porque «con quién hablo» y «a través de qué organización» son dos datos distintos, y el segundo es el que decide si la conversación sigue siendo legítima mañana."
          },
          "organization_name": {
            "type": "string",
            "examples": [
              "Acme S.L."
            ]
          },
          "organization_slug": {
            "type": [
              "string",
              "null"
            ],
            "description": "Slug de esa organización, para desambiguar dos nombres iguales."
          }
        }
      },
      "chat_external_dm_in": {
        "type": "object",
        "title": "ChatExternalDmIn",
        "description": "Idempotente, igual que el DM interno: si ya existe el directo entre las dos personas se devuelve el que hay, en vez de crear un segundo. `organization_id` es la organización DE LA OTRA PERSONA por la que se la alcanza — se manda explícita y no se deduce, porque quien está en varias organizaciones es alcanzable por varias puertas y cuál se usó es lo que decide qué ajuste puede cerrar luego la conversación.",
        "required": [
          "user_id",
          "organization_id"
        ],
        "properties": {
          "user_id": {
            "type": "string",
            "format": "uuid",
            "description": "Persona con la que se abre el directo."
          },
          "organization_id": {
            "type": "string",
            "format": "uuid",
            "description": "Organización de esa persona (una de las que devuelve el directorio). Se valida contra su membresía real: un id que no sea suyo responde 404, no se confía en lo que manda el cliente."
          }
        }
      },
      "chat_unread_count": {
        "type": "object",
        "title": "ChatUnreadCount",
        "required": [
          "count"
        ],
        "properties": {
          "count": {
            "type": "integer"
          }
        }
      },
      "chat_reaction_in": {
        "type": "object",
        "title": "ChatReactionIn",
        "required": [
          "emoji"
        ],
        "properties": {
          "emoji": {
            "type": "string",
            "minLength": 1,
            "maxLength": 32
          }
        }
      },
      "chat_message_pin_in": {
        "title": "ChatMessagePinIn",
        "type": "object",
        "description": "Pinear un mensaje con una duración fija (1 semana / 15 días / 30 días) o «siempre».",
        "required": [
          "duration"
        ],
        "properties": {
          "duration": {
            "type": "string",
            "enum": [
              "1w",
              "15d",
              "30d",
              "forever"
            ],
            "description": "Duración del pin: 1w (1 semana), 15d, 30d o forever (siempre)."
          }
        }
      },
      "chat_mute_in": {
        "title": "ChatMuteIn",
        "type": "object",
        "description": "Silenciar (true) o reactivar (false) un canal para mí (F1.8).",
        "required": [
          "muted"
        ],
        "properties": {
          "muted": {
            "type": "boolean"
          }
        }
      },
      "chat_user_presence": {
        "title": "ChatUserPresence",
        "type": "object",
        "description": "Presencia de un usuario — conectado, última conexión y estado elegido (P4).",
        "required": [
          "user_id",
          "online",
          "last_seen",
          "status"
        ],
        "properties": {
          "user_id": {
            "type": "string",
            "format": "uuid"
          },
          "online": {
            "type": "boolean",
            "description": "Con heartbeat activo (TTL ~30 s)."
          },
          "last_seen": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Última conexión."
          },
          "status": {
            "type": "string",
            "enum": [
              "online",
              "away",
              "busy",
              "offline"
            ]
          }
        }
      },
      "chat_status_in": {
        "title": "ChatStatusIn",
        "type": "object",
        "description": "Estado de chat que el usuario fija para sí mismo (P4).",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "online",
              "away",
              "busy",
              "offline"
            ]
          }
        }
      },
      "usage_item": {
        "title": "UsageItem",
        "type": "object",
        "description": "Lo que UNA persona ha consumido de UN recurso en el periodo, y de cuánto dispone.\n\nLos tres campos numéricos son nulables a propósito, y la distinción decide qué botón enseña la interfaz: `included: null` significa **este recurso no está en tu edición** —se arregla cambiando de plan—, mientras que `remaining: 0` significa **se acabó este mes** y se arregla esperando al día 1. Si fueran enteros a secas, las dos situaciones se leerían igual y llevarían al sitio equivocado.",
        "required": [
          "resource",
          "scope",
          "included",
          "used",
          "remaining"
        ],
        "properties": {
          "resource": {
            "type": "string",
            "enum": [
              "ia_tokens",
              "video_minutos",
              "ejecuciones"
            ],
            "description": "`ia_tokens` son tokens del modelo (entrada + salida sumados: los dos cuestan). `video_minutos` son minutos-participante, que es como factura LiveKit — una reunión de diez minutos con cuatro personas son cuarenta. `ejecuciones` son disparos de una regla de automatización: se cuentan los que llegan a APLICARSE, no las reglas que se miran."
          },
          "scope": {
            "type": "string",
            "enum": [
              "persona",
              "organizacion"
            ],
            "description": "De quién es el cupo. `persona`: la licencia va por email y no se comparte, así que lo que no gasta uno no se lo regala a otro. `organizacion`: un depósito común, y lo que gasta uno se lo gasta a todos.\n\nNo es un detalle de implementación: decide si «lo que queda» es tuyo o de la casa, y repartirlo mal se nota enseguida."
          },
          "included": {
            "type": [
              "integer",
              "null"
            ],
            "description": "El cupo del periodo. `null` = el recurso no está en tu edición.",
            "examples": [
              5000000
            ]
          },
          "used": {
            "type": "integer",
            "description": "Lo gastado contra ese cupo, en el ámbito que dice `scope`."
          },
          "remaining": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Lo que queda, con suelo en cero. `null` cuando `included` lo es — nunca cero, que significaría otra cosa."
          }
        }
      },
      "usage_me": {
        "title": "UsageMe",
        "type": "object",
        "description": "Mi consumo del periodo en curso, un item por recurso.\n\nSalen TODOS los recursos, incluidos los que esta edición no ofrece — con `included: null`. Es lo que deja a la pantalla decir «Kern es de Pro» en vez de no enseñar nada: un hueco en blanco se lee como que algo falló, no como una decisión de producto.",
        "required": [
          "period",
          "items"
        ],
        "properties": {
          "period": {
            "type": "string",
            "description": "`YYYY-MM` en UTC. El mes de facturación es el del calendario.",
            "examples": [
              "2026-08"
            ]
          },
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/usage_item"
            }
          }
        }
      },
      "usage_row": {
        "title": "UsageRow",
        "type": "object",
        "description": "Lo que ha gastado UNA persona del recurso consultado.",
        "required": [
          "user_id",
          "name",
          "email",
          "used",
          "remaining"
        ],
        "properties": {
          "user_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "email": {
            "type": "string"
          },
          "used": {
            "type": "integer"
          },
          "remaining": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Lo que le queda a ESA persona. `null` si el recurso no está en la edición, y también si el cupo es de la ORGANIZACIÓN: ahí «lo que le queda a uno» no significa nada —el depósito es común— y repetir el mismo número en cada fila invitaría a sumarlos."
          }
        }
      },
      "usage_breakdown": {
        "title": "UsageBreakdown",
        "type": "object",
        "description": "Quién ha gastado qué, de mayor a menor. Admin+.\n\nSolo aparece quien tiene consumo: un listado con ochenta personas a cero para encontrar a las tres que gastan es un listado que nadie lee, y el que falta se deduce — si no está, no gastó.",
        "required": [
          "period",
          "resource",
          "scope",
          "included_per_person",
          "total_used",
          "rows"
        ],
        "properties": {
          "period": {
            "type": "string",
            "examples": [
              "2026-08"
            ]
          },
          "resource": {
            "type": "string",
            "enum": [
              "ia_tokens",
              "video_minutos",
              "ejecuciones"
            ]
          },
          "scope": {
            "type": "string",
            "enum": [
              "persona",
              "organizacion"
            ]
          },
          "included_per_person": {
            "type": [
              "integer",
              "null"
            ],
            "description": "El cupo de CADA persona cuando `scope` es `persona`. `null` cuando el recurso no está en la edición o cuando el depósito es de la organización."
          },
          "total_used": {
            "type": "integer"
          },
          "rows": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/usage_row"
            }
          }
        }
      },
      "usage_period": {
        "title": "UsagePeriod",
        "type": "object",
        "required": [
          "period",
          "total"
        ],
        "properties": {
          "period": {
            "type": "string",
            "description": "`YYYY-MM`.",
            "examples": [
              "2026-08"
            ]
          },
          "total": {
            "type": "integer",
            "description": "Lo gastado por TODA la organización ese mes."
          }
        }
      },
      "usage_history": {
        "title": "UsageHistory",
        "type": "object",
        "description": "El total de la organización, mes a mes, del más reciente al más antiguo. Admin+.\n\nLos meses SIN consumo NO salen: no hay fila que sumar. Quien pinte la serie tiene que rellenar los huecos con cero — una gráfica que se salta un mes vacío miente sobre la pendiente.",
        "required": [
          "resource",
          "periods"
        ],
        "properties": {
          "resource": {
            "type": "string",
            "enum": [
              "ia_tokens",
              "video_minutos",
              "ejecuciones"
            ]
          },
          "periods": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/usage_period"
            }
          }
        }
      },
      "meeting": {
        "title": "Meeting",
        "type": "object",
        "description": "Reunión/llamada (sala LiveKit) de una organización.",
        "required": [
          "id",
          "organization_id",
          "channel_id",
          "client_id",
          "title",
          "kind",
          "mode",
          "slug",
          "room_name",
          "status",
          "scheduled_start",
          "scheduled_end",
          "started_at",
          "ended_at",
          "calendar_event_id",
          "created_by",
          "join_path",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "channel_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "client_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Cliente con el que es la reunión; `null` en las reuniones internas. Se pone al crearla y es lo que la hace aparecer en el timeline de la ficha del cliente. Borrar el cliente la deja a `null` (ON DELETE SET NULL): la reunión ocurrió, y perder al cliente no la borra del histórico."
          },
          "title": {
            "type": [
              "string",
              "null"
            ]
          },
          "kind": {
            "type": "string",
            "enum": [
              "meeting",
              "call"
            ],
            "description": "Qué ES esto: una reunión convocada o una llamada que alguien empezó. Hasta la migración 0193 no existía y se deducía de `scheduled_start IS NULL`, que era ambiguo en los dos sentidos — «Empezar ahora» desde Reuniones produce la misma fila que una llamada del chat, y una llamada agendada a una hora era inexpresable. De ahí que una llamada saliera rotulada como «Reunión sin título»."
          },
          "mode": {
            "type": "string",
            "enum": [
              "audio",
              "video"
            ],
            "description": "Con cámara (`video`) o solo con voz (`audio`).\n\nEs un dato de la REUNIÓN y no de la navegación de quien la crea, y ésa es la razón de que exista como campo: el modo tiene que llegar al OTRO lado. Un `?audio=1` en el enlace lo sabría quien llama y nadie más — quien recibe el aviso leería «videollamada» y abriría la cámara al entrar."
          },
          "slug": {
            "type": "string"
          },
          "room_name": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "scheduled",
              "live",
              "ended"
            ]
          },
          "scheduled_start": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "NULL = llamada instantánea."
          },
          "scheduled_end": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "started_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cuándo empezó DE VERDAD: el instante en que se firmó el primer token de sala. `null` mientras nadie ha entrado. No es `scheduled_start`, que es lo que se había planificado y que para una llamada instantánea no existe."
          },
          "ended_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cuándo terminó de verdad (`POST /meetings/{id}/end`). `null` mientras siga viva. Con `started_at`, es lo que permite decir cuánto duró — antes de la 0193 la duración no era una consulta difícil, era una pregunta sin respuesta en el modelo."
          },
          "calendar_event_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "join_path": {
            "type": "string",
            "description": "Ruta del link único, p. ej. /meet/ab12cd34."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "meeting_create_in": {
        "title": "MeetingCreateIn",
        "type": "object",
        "description": "Crear reunión. Sin scheduled_start = llamada instantánea.",
        "properties": {
          "title": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255
          },
          "kind": {
            "type": "string",
            "enum": [
              "meeting",
              "call"
            ],
            "description": "`call` cuando quien la crea SABE que es una llamada (el botón de llamar del chat). OPCIONAL: omitirlo crea una reunión, que es lo que era todo antes de existir este campo — un cliente viejo sigue creando exactamente lo que creaba.\n\nEl valor por defecto va en la descripción y NO en un `default:` del esquema a propósito: `openapi-typescript` convierte en REQUERIDA toda propiedad con `default`, así que declararlo obligaría a los cuatro sitios del front que crean reuniones a escribir `kind: 'meeting'` para decir lo que ya es. El servidor lo aplica igual (`MeetingCreateIn.kind = \"meeting\"` en Pydantic)."
          },
          "mode": {
            "type": "string",
            "enum": [
              "audio",
              "video"
            ],
            "description": "`audio` para una llamada sin cámara. OPCIONAL: omitirlo crea una videollamada, que es lo que hacía el único botón que había.\n\nIgual que `kind`, el valor por defecto va en la descripción y no en un `default:` del esquema: `openapi-typescript` convierte en REQUERIDA toda propiedad con `default`. El servidor lo aplica desde Pydantic."
          },
          "channel_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Canal de chat ligado (opcional)."
          },
          "client_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Cliente con el que es la reunión (opcional). Debe ser un cliente de la MISMA organización; uno de otra responde 404 `not_found`, igual que el canal. Es lo que hace que la reunión aparezca en el timeline de su ficha: sin esta columna no había forma de saber de qué cliente era una reunión (`channel_id` lleva a un canal de chat y `calendar_event_id` no es una FK)."
          },
          "scheduled_start": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "scheduled_end": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "meeting_invitation": {
        "title": "MeetingInvitation",
        "type": "object",
        "description": "Una invitación del usuario autenticado a una reunión, con el resumen de la reunión embebido (para pintar la lista \"mis invitaciones\" sin una segunda llamada).",
        "required": [
          "id",
          "status",
          "invited_by",
          "created_at",
          "responded_at",
          "meeting"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "accepted",
              "declined"
            ]
          },
          "invited_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "responded_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "meeting": {
            "$ref": "#/components/schemas/meeting"
          }
        }
      },
      "meeting_capabilities": {
        "title": "MeetingCapabilities",
        "type": "object",
        "description": "Qué puede hacer con vídeo esta INSTALACIÓN de Projekt. No habla del plan ni de permisos: dice si el operador ha configurado LiveKit (`LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`). Existe para que la interfaz pueda avisar ANTES de crear una reunión —\"la videollamada no está disponible en esta instalación\"— en vez de dejar al usuario chocar con el 503 `livekit_unconfigured` del endpoint de token.",
        "required": [
          "video_enabled",
          "reason"
        ],
        "properties": {
          "video_enabled": {
            "type": "boolean",
            "description": "`true` si LiveKit está configurado y el backend puede firmar tokens de sala."
          },
          "reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Motivo cuando `video_enabled` es `false`; `null` cuando está habilitado. `livekit_unconfigured` = esta instalación no tiene las variables LIVEKIT_*."
          }
        }
      },
      "meeting_update_in": {
        "title": "MeetingUpdateIn",
        "type": "object",
        "description": "Editar una reunión. Todos los campos son opcionales y solo se toca lo que llega: omitir `title` lo deja como estaba, y mandarlo a `null` lo borra. Es la distinción que un PATCH tiene que sostener, porque «no lo cambies» y «déjalo vacío» son dos peticiones distintas y las dos son legítimas.\n\nLo que NO se puede editar, y por qué: `kind` (una llamada no se convierte en reunión a posteriori — sería reescribir lo que pasó), `slug` y `room_name` (son el enlace que ya se ha repartido), y `status`, `started_at` y `ended_at` (los escribe el servidor a partir de hechos, no de opiniones).",
        "properties": {
          "title": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255,
            "description": "Nuevo título; `null` lo deja sin título."
          },
          "scheduled_start": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Nueva hora de inicio; `null` la convierte en una reunión sin hora (empieza cuando alguien entra)."
          },
          "scheduled_end": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "meeting_join_token": {
        "title": "MeetingJoinToken",
        "type": "object",
        "description": "Token de acceso LiveKit + datos de conexión para unirse a la sala.",
        "required": [
          "token",
          "url",
          "room",
          "identity"
        ],
        "properties": {
          "token": {
            "type": "string",
            "description": "JWT de acceso LiveKit (HS256)."
          },
          "url": {
            "type": "string",
            "description": "URL wss:// pública del servidor LiveKit."
          },
          "room": {
            "type": "string"
          },
          "identity": {
            "type": "string",
            "description": "Identidad de ESTA conexión dentro de la sala LiveKit: `<user_id>#<sufijo>`. NO es el `user_id` a secas, y desde el 31/08/2026 tampoco un UUID. LiveKit expulsa al participante anterior cuando entra otro con la misma identidad, así que dos clientes de la misma persona —la ventana del escritorio y la pestaña del enlace— se echaban mutuamente en bucle. El `user_id` sigue delante del `#` para quien tenga que ir de participante a usuario; el nombre que se pinta va en el claim `name` del token."
          }
        }
      },
      "meeting_invite": {
        "title": "MeetingInvite",
        "type": "object",
        "description": "Invitación explícita de un usuario (miembro de la org) a una reunión. `status` refleja el RSVP del invitado (pending hasta que acepta/rechaza).",
        "required": [
          "id",
          "meeting_id",
          "organization_id",
          "user_id",
          "invited_by",
          "status",
          "created_at",
          "responded_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "meeting_id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "user_id": {
            "type": "string",
            "format": "uuid",
            "description": "Usuario invitado (miembro de la org)."
          },
          "invited_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Quién invitó; null si el usuario ya no existe."
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "accepted",
              "declined"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "responded_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Cuándo respondió (aceptó/rechazó); null si sigue pendiente."
          }
        }
      },
      "meeting_invite_create_in": {
        "title": "MeetingInviteCreateIn",
        "type": "object",
        "description": "Invitar a uno o más miembros de la organización a una reunión. Cada invitado recibe una notificación `meeting_invite`. IDs que no sean miembros de la org se rechazan (422).",
        "required": [
          "user_ids"
        ],
        "properties": {
          "user_ids": {
            "type": "array",
            "minItems": 1,
            "maxItems": 100,
            "uniqueItems": true,
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "description": "IDs de usuarios (miembros de la org) a invitar."
          }
        }
      },
      "meeting_invite_respond_in": {
        "title": "MeetingInviteRespondIn",
        "type": "object",
        "description": "Responder a mi propia invitación a una reunión (RSVP).",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "accepted",
              "declined"
            ]
          }
        }
      },
      "meeting_admission": {
        "title": "MeetingAdmission",
        "type": "object",
        "description": "Solicitud de admisión (sala de espera) de un invitado sin acceso directo a la sala de vídeo. El anfitrión la admite o rechaza; el invitado no obtiene token LiveKit hasta ser admitido.",
        "required": [
          "id",
          "meeting_id",
          "organization_id",
          "user_id",
          "status",
          "decided_by",
          "created_at",
          "decided_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "meeting_id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "user_id": {
            "type": "string",
            "format": "uuid",
            "description": "Quién pide entrar."
          },
          "status": {
            "type": "string",
            "enum": [
              "requested",
              "admitted",
              "denied"
            ]
          },
          "decided_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Anfitrión/admin que decidió; null mientras está pendiente."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "decided_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "meeting_admission_decide_in": {
        "title": "MeetingAdmissionDecideIn",
        "type": "object",
        "description": "Decisión del anfitrión sobre una solicitud de admisión a la sala.",
        "required": [
          "action"
        ],
        "properties": {
          "action": {
            "type": "string",
            "enum": [
              "admit",
              "deny"
            ]
          }
        }
      },
      "key_result": {
        "title": "KeyResult",
        "type": "object",
        "description": "Resultado clave medible de un objetivo (current_value hacia target_value).",
        "required": [
          "id",
          "objective_id",
          "title",
          "target_value",
          "current_value",
          "unit",
          "progress",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "objective_id": {
            "type": "string",
            "format": "uuid"
          },
          "title": {
            "type": "string"
          },
          "target_value": {
            "type": "string",
            "description": "Valor objetivo (decimal como string)."
          },
          "current_value": {
            "type": "string",
            "description": "Valor actual (decimal como string)."
          },
          "unit": {
            "type": [
              "string",
              "null"
            ],
            "description": "Unidad libre: %, €, clientes…"
          },
          "progress": {
            "type": "number",
            "format": "float",
            "minimum": 0,
            "maximum": 100,
            "description": "Derivado: current/target * 100, recortado a [0, 100]."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "objective": {
        "title": "Objective",
        "type": "object",
        "description": "Objetivo OKR de una organización, acotado a un periodo (p. ej. \"2026-Q3\").",
        "required": [
          "id",
          "organization_id",
          "title",
          "description",
          "period",
          "owner_id",
          "parent_id",
          "status",
          "progress",
          "key_results",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "title": {
            "type": "string"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "period": {
            "type": "string",
            "description": "Periodo libre estilo 2026-Q3 / 2026 / 2026-07."
          },
          "owner_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Objetivo PADRE (alineación jerárquica OKR); null = objetivo raíz."
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "done",
              "archived"
            ]
          },
          "progress": {
            "type": "number",
            "format": "float",
            "minimum": 0,
            "maximum": 100,
            "description": "Media simple del progreso de sus KRs (0 si no tiene)."
          },
          "key_results": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/key_result"
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "key_result_create_in": {
        "title": "KeyResultCreateIn",
        "type": "object",
        "description": "Crear un resultado clave. current_value por defecto 0.",
        "required": [
          "title",
          "target_value"
        ],
        "properties": {
          "title": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200
          },
          "target_value": {
            "type": "number",
            "description": "Valor objetivo (se acepta número o string decimal)."
          },
          "current_value": {
            "type": "number",
            "default": 0
          },
          "unit": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 20
          }
        }
      },
      "objective_create_in": {
        "title": "ObjectiveCreateIn",
        "type": "object",
        "description": "Crear un objetivo (opcionalmente con sus KRs iniciales en el mismo POST).",
        "required": [
          "title",
          "period"
        ],
        "properties": {
          "title": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "period": {
            "type": "string",
            "pattern": "^[A-Za-z0-9-]{1,20}$"
          },
          "owner_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Objetivo PADRE ya existente de la MISMA organización (alineación)."
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "done",
              "archived"
            ],
            "default": "active"
          },
          "key_results": {
            "type": "array",
            "default": [],
            "items": {
              "$ref": "#/components/schemas/key_result_create_in"
            }
          }
        }
      },
      "objective_update_in": {
        "title": "ObjectiveUpdateIn",
        "type": "object",
        "description": "Editar un objetivo (PATCH parcial). No toca los KRs (tienen sus propios endpoints).",
        "properties": {
          "title": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 1,
            "maxLength": 200
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "period": {
            "type": [
              "string",
              "null"
            ],
            "pattern": "^[A-Za-z0-9-]{1,20}$"
          },
          "owner_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Mueve el objetivo dentro de la jerarquía; null desalinea (vuelve a ser raíz). Un parent_id que crearía un ciclo (el objetivo como su propio ancestro) se rechaza con 422."
          },
          "status": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "active",
              "done",
              "archived"
            ]
          }
        }
      },
      "key_result_update_in": {
        "title": "KeyResultUpdateIn",
        "type": "object",
        "description": "Editar un resultado clave (PATCH parcial). Mover current_value refleja el avance.",
        "properties": {
          "title": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 1,
            "maxLength": 200
          },
          "target_value": {
            "type": [
              "number",
              "null"
            ]
          },
          "current_value": {
            "type": [
              "number",
              "null"
            ]
          },
          "unit": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 20
          }
        }
      },
      "key_result_snapshot": {
        "title": "KeyResultSnapshot",
        "type": "object",
        "description": "Punto histórico e inmutable del progreso de un KeyResult (para pintar una gráfica de evolución). value/target_value son los vigentes EN ESE MOMENTO (se congelan al registrar el snapshot: si el target cambia después, este punto no se reescribe).",
        "required": [
          "id",
          "key_result_id",
          "value",
          "target_value",
          "progress",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "key_result_id": {
            "type": "string",
            "format": "uuid"
          },
          "value": {
            "type": "string",
            "description": "Valor registrado en ese instante (decimal como string)."
          },
          "target_value": {
            "type": "string",
            "description": "Valor objetivo vigente en ese instante (decimal como string)."
          },
          "progress": {
            "type": "number",
            "format": "float",
            "minimum": 0,
            "maximum": 100,
            "description": "Derivado: value/target_value * 100 en ese instante, recortado a [0, 100]."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "key_result_snapshot_create_in": {
        "title": "KeyResultSnapshotCreateIn",
        "type": "object",
        "description": "Registra un snapshot de progreso: mueve current_value del KR a value y deja rastro en el historial (a diferencia de PATCH, que también puede tocar título/target/unidad, esto es solo \"registrar el avance de hoy\").",
        "required": [
          "value"
        ],
        "properties": {
          "value": {
            "type": "number",
            "description": "Nuevo valor actual (se acepta número o string decimal)."
          }
        }
      },
      "vapid_public_key": {
        "type": "object",
        "title": "VapidPublicKey",
        "required": [
          "public_key"
        ],
        "properties": {
          "public_key": {
            "type": "string",
            "description": "Clave pública VAPID en base64url, o \"\" si push está deshabilitado."
          }
        }
      },
      "gantt_dependency_link": {
        "type": "object",
        "title": "GanttDependencyLink",
        "description": "Compact dependency edge embedded inside a GanttTask.",
        "required": [
          "target_task_id",
          "dep_type"
        ],
        "properties": {
          "target_task_id": {
            "type": "string",
            "format": "uuid",
            "description": "The task this dependency points to."
          },
          "dep_type": {
            "type": "string",
            "enum": [
              "blocks",
              "blocked_by",
              "relates_to"
            ]
          }
        }
      }
    }
  }
}
