# Pireon Admin Panel de administración y API REST en Laravel 13 + Filament 5 para un colectivo de programadores sin ánimo de lucro. Autenticación delegada en el servidor de identidad **Pireon** (`https://oauth.pireon.dev`). **Este repositorio es solo admin + API.** La web pública es una aplicación aparte, con **renderizado en servidor (SSR)**, que consume `/api/public/v1`. Con qué framework esté hecha —Astro, Qwik, Angular— es indiferente para este lado, y por eso no se nombra ninguno en el código: lo único que esta API da por supuesto es el SSR. Aquí no hay maquetación: el renderizado, las rutas, el sitemap y las etiquetas `hreflang` son cosa del frontend. Lo que sí vive aquí es el dato que necesita para hacerlo. ## Las dos ideas que lo estructuran todo **1. El servidor OAuth dice quién eres; esta aplicación decide qué puedes hacer.** | | Lo gestiona | Dónde se cambia | |---|---|---| | Nombre, email, cuenta activa/aprobada | Servidor de identidad | `oauth.pireon.dev` | | Roles y permisos | Esta aplicación | Panel → Administración → Roles | | Contenido, idiomas, repositorios | Esta aplicación | Panel | Los `app_roles` que devuelve el servidor OAuth se guardan en `users.remote_roles` como información de apoyo, pero **no conceden nada**. **2. Un miembro del colectivo no es un usuario del panel.** La mayoría de la gente que aparece en la web nunca necesita entrar al admin. `Member` tiene una relación *opcional* con `User`, así que quitarle a alguien el acceso de administración no lo borra del sitio. ## Manual de uso Para quien **usa** el panel (no para quien lo despliega) hay un manual aparte: [`docs/manual-de-uso.pdf`](docs/manual-de-uso.pdf) — 26 páginas cubriendo idiomas y traducciones que se quedan atrás, proyectos, publicación programada, la vitrina, convocatorias, el buzón, imágenes, texto alternativo y galerías, miembros, radar, repositorios de Gitea, pedir revisión, redirecciones y URLs que nadie encuentra, salud del sitio y visitas, papelera, historial, mantenimiento y la tabla de qué puede hacer cada rol. Se genera desde [`docs/manual-de-uso.html`](docs/manual-de-uso.html): ```bash node docs/build-pdf.mjs ``` ## Puesta en marcha ```bash composer install cp .env.example .env # si aún no existe php artisan key:generate php artisan migrate --seed # crea roles, permisos y contenido de ejemplo ``` `composer install` activa también [`.githooks/pre-commit`](.githooks/pre-commit) (`git config core.hooksPath .githooks`, dentro de `post-autoload-dump`): antes de cada commit corre `pint --dirty --test` sobre lo que has tocado y lo rechaza si algo no está formateado. Solo avisa, nunca reescribe por su cuenta un fichero que estás a punto de comitear — el arreglo es `pint --dirty` a mano, ver el diff, y comitear otra vez. Nada de esto sustituye a la CI: el hook protege el minuto a minuto de quien escribe, la CI protege lo que llega al remoto por si alguien comitea con `--no-verify`. ### 1. Registrar la aplicación en el servidor de identidad En `https://oauth.pireon.dev` crea un cliente OAuth con: - **Grant types**: `password` — es el que usa el formulario de acceso del panel - **Redirect URI**: `https://tu-dominio/auth/callback` (en local, `http://localhost:8000/auth/callback`), solo si quieres conservar el flujo de redirección, que sigue disponible aunque el panel ya no lo use Copia las credenciales al `.env`: ```dotenv PIREON_SERVER=https://oauth.pireon.dev PIREON_CLIENT_ID=... PIREON_CLIENT_SECRET=... PIREON_REDIRECT_URI="${APP_URL}/auth/callback" ``` Mientras `PIREON_CLIENT_ID` esté vacío, la pantalla de login lo indica en vez de fallar contra el servidor. ### 2. Crear el primer administrador No hay usuarios locales hasta que alguien entra por OAuth. El arranque es: ```bash php artisan serve # entra en http://localhost:8000/admin y haz login con tu cuenta de Pireon php artisan user:role tu@email.com super_admin ``` Tu primer login te deja con el rol `viewer` (configurable en `PIREON_DEFAULT_ROLE`); el comando te promociona a super admin. A partir de ahí todo se gestiona desde el panel. ## Autenticación ### Panel — email y contraseña `/admin/login` es un formulario normal. Quien entra no ve nada de OAuth: el servidor de identidad es un detalle de implementación. ``` POST /admin/login → grant "password" contra oauth.pireon.dev → GET /api/user para saber quién es → crea o actualiza el usuario local, inicia sesión ``` **No hay contraseñas locales.** La columna `password` no se usa nunca: quien decide si las credenciales son correctas sigue siendo el servidor de identidad. Desactivar a alguien allí, o aquí (`is_active = false`), le corta el acceso **a la próxima petición** — la sesión ya abierta seguía existiendo hasta ahora, sin que nadie pudiera verla ni cerrarla desde aquí. En **Administración → Usuarios → [nombre] → Sesiones** se ve desde dónde tiene cada cuenta la sesión abierta ahora mismo — IP, navegador, última actividad — leído directamente de la tabla `sessions` que el driver `database` de Laravel ya mantiene; no se escribe nada nuevo, solo se hace visible. Con botón de **Cerrar sesión** por cada una, salvo la que la propia persona que mira la pantalla está usando en ese momento: ofrecer cerrarla dejaría un clic capaz de desconectarte a ti mismo a mitad de gestionar a otro. El precio de esta comodidad, dicho claro: **esta aplicación ve la contraseña**. No la guarda, no la registra y no sale de la petición en la que llega —va directa al servidor de identidad—, pero un compromiso de este panel expondría credenciales del servidor de identidad, cosa que el flujo de redirección hacía imposible. Es una decisión deliberada, no un descuido. Lo que sí se mantiene: - **Cinco intentos** por IP y a esperar (el limitador de Filament), para no gastar el del servidor de identidad. - **Un solo mensaje de error** para credenciales incorrectas y para direcciones que no existen: el formulario no sirve para averiguar quién tiene cuenta. - **Pero un servidor de identidad caído no se disfraza de contraseña incorrecta.** Si responde 5xx o no responde, el mensaje lo dice: no ha juzgado nada sobre esas credenciales, y hacer creer lo contrario manda a alguien a revisar una contraseña que nunca fue el problema. Lo mismo en `POST /api/v1/auth/token`, que devuelve 503 en vez de 401. - **Rotación del id de sesión** al entrar, contra fijación de sesión. - Cuenta desactivada, pendiente de aprobación o sin rol en este panel: mensajes distintos, pero solo **después** de acertar la contraseña. Las rutas de **Authorization Code + PKCE** siguen ahí ([`PireonAuthController`](app/Http/Controllers/Auth/PireonAuthController.php), `/auth/redirect` y `/auth/callback`) y funcionando, simplemente ya no se anuncian. Volver a ese flujo es cambiar la página de login del panel. Requiere `PIREON_PASSWORD_GRANT=true` y que el cliente OAuth registrado permita el grant `password`. Si está desactivado, el formulario lo dice en vez de fingir que las credenciales son incorrectas. Los tokens viven en la sesión ([`TokenStore`](app/Services/Pireon/TokenStore.php)), se refrescan solos al caducar y se borran al cerrar sesión. ### API — Bearer token El servidor de identidad no publica JWKS ni endpoint de introspección, así que [`PireonTokenGuard`](app/Auth/PireonTokenGuard.php) valida cada token gastándolo contra `GET /api/user`. El resultado se cachea unos segundos (`PIREON_INTROSPECTION_TTL`), indexado por un hash SHA-256 del token — el token en sí nunca llega al almacén de caché. Ese TTL es la ventana durante la cual un token revocado arriba sigue funcionando aquí. Mantenlo bajo. Desactivar un usuario en este panel (`is_active = false`) le corta el acceso al instante, sin tocar el servidor OAuth. ### Password grant Para clientes que no pueden abrir un navegador (tests, apps móviles): ```bash curl -X POST http://localhost:8000/api/v1/auth/token \ -H "Content-Type: application/json" \ -d '{"grant_type":"password","username":"tu@email.com","password":"..."}' ``` Es el mismo grant que usa el formulario del panel, así que `PIREON_PASSWORD_GRANT=false` desactiva los dos a la vez. ## Permisos El catálogo vive en un único sitio: [`app/Support/Permissions.php`](app/Support/Permissions.php). ```php public const RESOURCES = [ 'pages' => [...self::CRUD_ABILITIES, 'publish'], 'categories' => self::CRUD_ABILITIES, 'projects' => [...self::CRUD_ABILITIES, 'publish'], 'members' => self::CRUD_ABILITIES, 'redirects' => ['view_any', 'view', 'create', 'update', 'delete'], 'services' => [...self::CRUD_ABILITIES, 'publish'], 'clients' => self::CRUD_ABILITIES, 'testimonials' => self::CRUD_ABILITIES, 'talks' => ['view_any', 'view', 'create', 'update', 'delete'], 'technologies' => ['view_any', 'view', 'create', 'update', 'delete'], 'calls' => ['view_any', 'view', 'create', 'update', 'delete'], 'applications' => ['view_any', 'view', 'update', 'delete', 'restore', 'force_delete'], 'repositories' => ['view_any', 'view', 'create', 'update', 'delete', 'sync'], 'users' => ['view_any', 'view', 'create', 'update', 'delete'], 'roles' => ['view_any', 'view', 'create', 'update', 'delete'], 'settings' => ['view', 'update', 'maintenance'], ]; ``` Añade un permiso ahí y ejecuta `php artisan permissions:sync`: aparece automáticamente en el editor de roles, en las policies y en la API. El sync es idempotente y borra los permisos que quites del catálogo. Roles que se siembran: - **super_admin** — sin permisos explícitos; lo puede todo por un `Gate::before`, así que nunca se queda desactualizado - **admin** — todo el catálogo - **editor** — todo el contenido (páginas, proyectos, miembros, tecnologías, convocatorias) y el buzón, porque quien lanza una convocatoria es quien tiene que contestar a quien responde; repositorios, usuarios y ajustes en solo lectura - **writer** — escribe páginas, proyectos y servicios y **no puede publicarlos**: es el rol para el que existían los permisos `publish` sueltos, y el que hace que el paso de [revisión](#revisión) tenga sentido. Sin buzón (mismo motivo que el viewer) y sin borrado definitivo - **viewer** — lectura de contenido, **sin** acceso al buzón Las [policies](app/Policies/) las comparten el panel y la API, que es lo que impide que las dos superficies se desincronicen. Incluyen las salvaguardas habituales: nadie se borra a sí mismo, no se elimina el último super admin, solo un super admin concede `super_admin`, y una categoría con páginas no se puede borrar. Estos permisos están separados a propósito: - `pages.publish` / `projects.publish` / `services.publish` — se puede escribir sin poder poner nada en la web pública. Es lo que hace posible el paso de [revisión](#revisión). - `repositories.sync` — lanzar una sincronización gasta el rate limit del proveedor de git, que es un recurso compartido por toda la aplicación. - `settings.maintenance` — tumbar la web pública es un acto deliberado y muy visible, así que no va con el resto de ajustes. - `applications.*` — el buzón guarda nombres, emails y mensajes de gente ajena al colectivo. No hay `applications.create`: las solicitudes llegan de la web, nadie las escribe a mano. ## Multilanguage Tres idiomas — **es** (por defecto), **en**, **ca** — configurados en [`config/locales.php`](config/locales.php). **Interfaz**: `laravel-lang` cubre validación y mensajes de framework; las cadenas propias están en `lang/{es,en,ca}/{panel,pireon,rules,enums}.php`. **Contenido**: `Page`, `Category`, `Project`, `Member`, `Technology` y `Call` usan `spatie/laravel-translatable`. Los campos traducibles se guardan como JSON `{"es": "...", "en": "...", "ca": "..."}`. En el panel se editan con una pestaña por idioma ([`TranslatableTabs`](app/Filament/Support/TranslatableTabs.php)), y una columna de estado muestra de un vistazo qué falta por traducir. Orden de resolución del idioma (de más a menos específico): 1. `?lang=ca` 2. sesión (lo que eligió en el selector) 3. `users.locale` 4. cabecera `Accept-Language` 5. `locales.default` En la API los campos traducibles se devuelven resueltos a un idioma. Con `?with=translations` llegan todos: ```bash curl '.../api/v1/pages?lang=ca' # title: "Inici" curl '.../api/v1/pages?with=translations' # translations.title.{es,en,ca} ``` Al escribir, un objeto parcial **fusiona**: enviar `{"title":{"ca":"..."}}` no borra el castellano. Los slugs son únicos **por idioma** ([`UniqueTranslatedSlug`](app/Rules/UniqueTranslatedSlug.php)); el mismo slug en dos idiomas distintos es válido. Al vivir dentro de JSON no puede ser una restricción de base de datos, así que se valida en la aplicación. Esa regla es dura y solo mira la dirección. Lo que no ve es un **título** casi idéntico con un slug distinto — el caso real: alguien no recuerda que el proyecto ya existe y empieza uno nuevo. Al escribir el título en páginas, proyectos y servicios, si algo existente se le parece mucho ([`DuplicateTitles`](app/Support/DuplicateTitles.php), comparación por semejanza de caracteres, sin operador de "parecido" común entre SQLite y Postgres), sale un aviso con la ficha existente enlazada. **No bloquea el guardado** — a veces el segundo es de verdad otra cosa que se llama casi igual —, solo evita que nadie se entere tarde. ## El modelo de contenido Cuatro modelos, pensados para lo que un colectivo así necesita de verdad: **Proyectos** — además de lo obvio, dos campos que son la razón de ser del modelo: `decisions` (por qué se eligió lo que se eligió) y `postmortem` (qué salió mal). Es lo que una consultora comercial no puede publicar y vosotros sí, y es lo que otros desarrolladores leen. Y `client`: para qué ONG o asociación se hizo, ahora como registro propio y no como texto suelto. **Servicios** — qué ofrece el colectivo, enlazado a los proyectos que lo demuestran. **Clientes** — para quién se hizo, con logo. **Testimonios** — qué dijeron, colgado de lo que avalan. **Charlas** — dónde se ha contado. **Miembros** — perfil público con bio traducible, stack y enlaces. Es el escaparate personal de cada uno, que es la moneda con la que se paga el trabajo voluntario. Quien se va conserva su historial (`left_at`) en vez de desaparecer. **Tecnologías** — vocabulario compartido entre proyectos, miembros y convocatorias, ahora con **logo propio** además del color —`icon` guardaba el nombre de un icono de un set que el frontend puede no traer, y la mayoría de tecnologías no están en ninguno—, y fuente del **tech radar** (`adopt` / `trial` / `assess` / `hold`, con la nota que explica el porqué). El `name` no se traduce: «Rust» es «Rust» en los tres idiomas; solo la prosa se traduce. **Convocatorias** — el punto de conversión. La clave son `hours_per_week` y `duration_weeks`: en un colectivo por hobby lo escaso es el tiempo, y «~4h/semana durante 8 semanas» permite decidir en diez segundos donde «buscamos frontend» no dice nada. `what_you_get` hace explícito el intercambio por trabajo no remunerado. Lo que la gente contesta a una convocatoria aterriza en el **buzón**, que es la otra mitad del punto de conversión: sin él la web enseña lo que hace falta y no recoge a quien se ofrece. ## Imágenes Una imagen dejó de ser una cadena. Un frontend que renderiza en servidor no puede maquetar bien con una ruta: sin `width` y `height` la página baila mientras cargan, sin `srcset` cada visitante se descarga el original de 2400px para un hueco de 400, y sin `alt` no es accesible. Todo eso se sabe aquí, así que se manda: ```json "cover_image": { "url": "https://admin.example/storage/projects/cover.jpg", "alt": "Captura del panel de la asociación", "width": 2400, "height": 1350, "aspect_ratio": 1.7778, "mime": "image/jpeg", "placeholder": "data:image/webp;base64,UklGRlIA...", "srcset": { "webp": ".../cover-400.webp 400w, .../cover-800.webp 800w, ..." } } ``` El panel sigue subiendo igual que antes: eliges un archivo y se guarda su ruta. Lo que cambia es lo que pasa después. **La tabla `media` es derivada.** Se identifica por (disco, ruta) —el archivo es la identidad—, así que dos registros apuntando a la misma subida comparten una fila y un solo juego de versiones. No se edita a mano: si se borrara entera se puede reconstruir con `php artisan media:process --all`, que es justo lo que hay que ejecutar tras cambiar los anchos o añadir un formato. **El alt vive en el registro, no en el archivo.** La misma foto puede necesitar una descripción distinta en un proyecto que en un listado, y solo el contexto sabe cuál. Por eso `pages.featured_image_alt` y `projects.cover_image_alt` son columnas traducibles del propio registro. Los avatares de miembros no tienen campo: el alt de un retrato es el nombre de la persona, y uno que hubiera que escribir solo se escribiría mal. **Nunca se amplía.** Un original de 900px genera las versiones de 400 y 800 y para. Republicar una imagen pequeña como «1600px» solo produce algo borroso. **AVIF se pide, no se supone.** Se genera solo si el PHP que ejecuta sabe codificarlo; si no, sale WebP y ya. Los vectores y los GIF pasan de largo sin tocarlos: remuestrear un SVG pierde el sentido y remuestrear un GIF pierde la animación. Del SVG sí se lee el `viewBox`, porque el hueco hay que reservarlo igual. **Se degrada a propósito.** Una imagen recién subida cuya cola aún no ha corrido sale con `url` y el resto a `null`: la página pinta un `` normal en vez de un agujero. ### El catálogo de medios **Administración → Medios** enseña todo lo subido: miniatura, medidas, cuántas versiones se derivaron, **cuánto pesa contando esas versiones** —el número que explica adónde se fue el disco— y si algo lo sigue usando. Filtros para las cuatro preguntas que no se pueden responder en ningún otro sitio: qué está **sin procesar**, qué **falló**, qué **no usa nadie** (lo mismo que se llevaría `content:prune --media`) y qué **ya no está en disco**. No se sube desde ahí: los archivos llegan al subirlos en una ficha (o por la API, abajo). Y **no se puede borrar uno que algo esté usando** — dejaría una imagen rota en la web, que es justo de lo que se queja la página de salud. El botón solo aparece en los huérfanos. «Reprocesar todo» es la respuesta a «he cambiado los anchos, ¿y ahora?»: la tabla es derivada, así que reconstruirla siempre es seguro. ### Subir por la API `POST /api/v1/media` es el campo de subida de una ficha, sin la ficha: guarda la imagen donde el panel pone las de ese tipo de ficha y contesta con el `path` que se manda como `featured_image` de una página o `logo` de una tecnología. `for` dice para qué es (`pages`, por defecto, `technologies`, `projects`, `members`, `clients` o `testimonials`), y con ello la carpeta y el permiso que hace falta. Existe para el [MCP](#servidor-mcp), que sin él podía crear una página pero nunca ponerle una foto. Pasa por `Media::for()`, así que se procesa, se sirve y se poda igual que una subida desde el panel. Dos formas de entrada, una por petición: **multipart** con `file`, o **JSON** con una `url` que la API descarga ella misma. JPEG, PNG, WebP, GIF, AVIF y SVG, decidido por los bytes y no por el nombre ni el tipo declarado. 20 MB como mucho (`MEDIA_UPLOAD_MAX_KB`). **El SVG entra, pero nunca tal cual**: es lo que es un logo, y también un documento que puede llevar script servido desde este dominio. Pasa antes por [`enshrined/svg-sanitize`](https://github.com/darylldoyle/svg-sanitizer), que quita scripts, manejadores de eventos y referencias a cualquier cosa fuera del fichero; y Caddy sirve los `/storage/*.svg` con una `Content-Security-Policy` que pararía un script igualmente si se colara. **La URL es la parte delicada**, porque la petición la hace este servidor desde dentro de la red del stack: sin control, sería una forma de pedirle `http://postgres:5432` o el servicio de metadatos de la nube. [`RemoteImage`](app/Services/Media/RemoteImage.php) exige HTTPS, resuelve el dominio y **rechaza si una sola de sus direcciones no es pública** (privadas, reservadas, loopback, link-local, 100.64/10, IPv4 mapeada en IPv6), **fija la conexión a la dirección comprobada** con `CURLOPT_RESOLVE` para que un DNS que cambie entre la comprobación y la petición no cuele otra, **no sigue redirecciones** y deja de leer al pasar del límite, diga lo que diga `Content-Length`. El permiso es escribir el tipo de ficha de `for` (`pages.create` o `pages.update`, `technologies.create` o…), no ninguno de `media.*`: esos siempre describieron cuidar ficheros que ya existen, y `MediaPolicy::create` sigue siendo `false` para que el panel no gane un botón de «nuevo fichero» suelto. ### Galerías Un proyecto tiene además una galería —capturas, el antes y el después— que se edita arrastrando desde su propia pestaña y sale en `gallery`, **solo en el endpoint de detalle**: un listado de veinte proyectos no necesita todas las capturas de cada uno. Cada imagen lleva dos textos distintos, y la diferencia importa: - **`alt`** — qué se ve, para quien no puede verlo. - **`caption`** — el pie, que se muestra a todo el mundo. «Antes» y «después» son pies, no descripciones. La tabla es polimórfica (`image_attachments`), así que darle galería a las páginas es añadir una relación `morphMany` y registrar el relation manager; no hace falta otra tabla. ```bash php artisan media:process # lo que la cola no haya hecho php artisan media:process --all # reprocesar todo ``` El scheduler lo ejecuta cada diez minutos, así que un worker caído cuesta un retraso y no una imagen a medias para siempre. **Nota de despliegue:** `MEDIA_DISK` tiene que ser un disco con URL pública (`public` requiere `php artisan storage:link`). Si tenéis subidas anteriores a esta versión están en el disco privado `local`: o las movéis a `storage/app/public` o ponéis `MEDIA_DISK=local`, que se sirve por la ruta `/storage` de la propia aplicación. ## Repositorios de git (Gitea) Los repositorios viven en **vuestra instancia de Gitea**. Se conectan indicando solo proveedor y `owner/repo`; todo lo demás —estrellas, lenguajes, contribuidores, último push, topics— lo trae la sincronización y no es editable a mano: un contador de estrellas escrito a dedo es una mentira esperando a ocurrir. ```dotenv GITEA_URL=https://git.example.org # raíz de la instancia, sin /api/v1 GITEA_TOKEN= # solo si hay repos privados GITEA_ORG=colectivo # permite escribir solo "mi-repo" ``` De cada repositorio se replican **solo las issues que sirven para entrar**: las etiquetadas como *good first issue* o *help wanted* (las variantes aceptadas están en [`config/git.php`](config/git.php), y el match ignora mayúsculas). Es lo más eficaz que puede tener una web así, porque convierte a alguien que pasaba por ahí en contribuidor con un clic. Las que se cierran arriba desaparecen aquí, para no hacer perder el tiempo a nadie. ```bash php artisan repos:sync # encola los que toque refrescar php artisan repos:sync --sync # en primer plano, con salida legible php artisan repos:sync colectivo/web --force ``` El scheduler lo ejecuta cada hora ([`routes/console.php`](routes/console.php)). Mantenerlo vivo importa más de lo que parece: **el mayor riesgo de una web así es parecer abandonada**, y lo que se actualiza solo vale más que la prosa que nadie va a revisar. El panel marca en rojo los repos que llevan varios intentos fallando (normalmente renombrados o borrados) y los que llevan medio año sin un push. ### Diferencias de Gitea que resuelve el driver La API de Gitea se parece a la de GitHub pero no es igual, y lo que cambia está encapsulado en [`GiteaProvider`](app/Services/Git/GiteaProvider.php): - Los contadores se llaman `stars_count` / `forks_count`, no `stargazers_count`. - Los topics están en su propio endpoint, no en el payload del repositorio. - Las issues aceptan `type=issues`, así que los pull requests se filtran en el servidor (en GitHub hay que descartarlos a mano). - La autenticación usa el esquema `token`, no `Bearer`. - **No hay endpoint de contribuidores.** El número se deriva de los autores distintos de los últimos 100 commits. Es una aproximación deliberada: para los repos de un colectivo pequeño el historial reciente cubre a todo el mundo que está realmente involucrado, y cuesta una petición en vez de recorrer el log. - No expone licencia en el payload del repo, así que ese campo queda vacío. Sin `GITEA_URL` el proveedor ni siquiera se ofrece en el panel: un driver que no sabe adónde apuntar solo produciría fallos más tarde. El driver de **GitHub** sigue disponible para mirrors o algún repo alojado fuera. Añadir GitLab es escribir una clase que implemente [`RepositoryProvider`](app/Services/Git/Contracts/RepositoryProvider.php) y registrarla en `config/git.php`, sin tocar nada más. ## Tableros en TaskStream Al crear un proyecto por la API, `taskstream: true` le abre también un tablero en [TaskStream](https://taskstream.pireon.dev), el gestor de tareas del colectivo, y la respuesta trae el enlace: ```json POST /api/v1/projects { "title": { "es": "Catálogo abierto" }, "slug": { "es": "catalogo" }, "taskstream": true } → 201 { "data": { …, "taskstream_key": "CATALOGO", "taskstream_url": "https://taskstream.pireon.dev/p/CATALOGO" } } ``` - **Público** siempre: es el tablero de un proyecto que se enseña. - **Clave**: la de `taskstream_key` (de 2 a 10 letras o cifras, empezando por letra) o, si no se indica, una sacada del slug en el idioma por defecto: de una palabra, sus diez primeros caracteres (`catalogo` → `CATALOGO`); de varias, sus iniciales (`mi-web-nueva` → `MWN`). - **Primero TaskStream**: si lo rechaza (una clave ya usada, por ejemplo), aquí no se crea nada y el motivo vuelve como error de validación (422); si no responde, 502. Así no quedan proyectos a medias ni webhooks de algo que no existe. - **A nombre de quien lo pide**: TaskStream acepta los mismos tokens de Pireon que esta API, y se le pasa el de quien llama. Un token de máquina (`pmt_…`) allí no significa nada, así que esas peticiones usan `TASKSTREAM_TOKEN`; sin él, no pueden pedir tablero. - Solo al crear: en una edición los dos campos se ignoran, y un proyecto duplicado no hereda el tablero del original. ```dotenv TASKSTREAM_API_URL=https://api.taskstream.pireon.dev # por defecto TASKSTREAM_WEB_URL=https://taskstream.pireon.dev # de aquí sale el enlace TASKSTREAM_TOKEN= # token personal de Pireon, solo para tokens de máquina TASKSTREAM_HTTP_TIMEOUT=10 ``` El cliente está en [`TaskStreamClient`](app/Services/TaskStream/TaskStreamClient.php) y la lógica (clave, token, errores) en [`OpenInTaskStream`](app/Actions/Projects/OpenInTaskStream.php). ## API pública (para el frontend) Sin autenticación y de solo lectura. Todo lo que devuelve ya está publicado en la web, así que pedir un token añadiría ceremonia sin añadir protección. Lo que **no** está publicado se protege con el secreto de preview. ``` GET /api/public/v1/site idiomas + cifras de cabecera GET /api/public/v1/slugs todas las URLs publicadas, por idioma GET /api/public/v1/projects[/{slug}] GET /api/public/v1/members[/{slug}] GET /api/public/v1/calls[/{slug}] GET /api/public/v1/pages[/{slug}] GET /api/public/v1/technologies GET /api/public/v1/radar GET /api/public/v1/good-first-issues POST /api/public/v1/calls/{slug}/apply responder a una convocatoria POST /api/public/v1/contact escribir sin convocatoria concreta POST /api/public/v1/telemetry/not-found lo que la web no supo resolver POST /api/public/v1/telemetry/views lo que la web ha mostrado ``` Los dos últimos llevan token (`X-Ingest-Token`): los llama el frontend en su propio nombre, no un visitante. Están descritos en [URLs que nadie encuentra](#urls-que-nadie-encuentra) y [Qué se lee de verdad](#qué-se-lee-de-verdad). Tres decisiones que conviene conocer: **Se resuelve por slug dentro del idioma activo**, no por id, porque es el patrón de acceso real de un frontend SSR: recibe una URL. Un slug catalán no resuelve en castellano, así que un mismo registro nunca responde en dos direcciones. **Cada respuesta trae `alternates`**, la ruta del mismo registro en cada idioma que tenga traducción: ```json { "es": "/proyectos/mi-web", "en": "/en/projects/my-web", "ca": "/ca/projectes/el-meu-web" } ``` Solo esta API sabe que esas tres URLs son la misma cosa, así que sin esto el frontend no puede emitir `hreflang` correctos y Google trata los tres idiomas como contenido duplicado. Los idiomas sin traducción se omiten, para no anunciar una URL que daría 404. **El rate limit está pensado para SSR.** Con renderizado en servidor todas las visitas llegan desde la IP del servidor del frontend; un límite por IP dimensionado para una persona estrangularía la web entera. De ahí `SITE_RATE_LIMIT` (600/min por defecto) y `SITE_TRUSTED_IPS` para saltárselo del todo. Las respuestas llevan `ETag`, `Cache-Control: s-maxage`, `stale-while-revalidate` y `Vary: Accept-Language`. Esta API está en el camino crítico de cada visita: si va lenta, la web va lenta. El `stale-while-revalidate` hace que una caída se degrade en contenido algo viejo en vez de una página rota. ### El buzón (los únicos endpoints de escritura) Hasta aquí la API pública era de solo lectura, así que una convocatoria terminaba en «esto es lo que necesitamos» sin ningún sitio donde contestar. Estos dos endpoints cierran ese circuito: ``` POST /api/public/v1/calls/{slug}/apply responder a una convocatoria POST /api/public/v1/contact escribir sin convocatoria concreta ``` ```bash curl -X POST https://admin.example/api/public/v1/calls/backend/apply \ -H 'Content-Type: application/json' -H 'Accept-Language: ca' \ -d '{"name":"Ada","email":"ada@example.org", "message":"Tinc unes hores els caps de setmana...", "hours_per_week":4,"consent":true}' ``` Lo que llega aparece en **Colectivo → Buzón**, con la campana del panel avisando a quien pueda leerlo. Cada solicitud guarda el idioma en que se escribió, para contestar en el mismo. Cinco decisiones que conviene conocer: **Solo una convocatoria abierta acepta solicitudes** (404 en cualquier otro caso). Hacer que alguien redacte un mensaje para un puesto ya cubierto es peor que no enseñarle formulario. **El honeypot archiva, no descarta.** Un envío con el campo trampa relleno se guarda como `spam` y responde 201 igual que el resto: el bot no aprende nada, y la persona real a la que el gestor de contraseñas le rellenó el campo oculto se puede rescatar desde la pestaña de spam. Un mensaje tirado a la basura no. **Repetir el envío no duplica.** El mismo email a la misma convocatoria dentro de la ventana de deduplicación (24 h) responde 200 con `duplicate: true` y no crea otra fila. Un doble clic en «enviar» no es una segunda persona, y devolverle un error solo hace que insista. **El límite de envíos no es por IP.** Con SSR todos los envíos del sitio llegan desde la IP del frontend —que además está en `SITE_TRUSTED_IPS`—, así que un límite por IP no protegería de nada. El presupuesto se gasta **por dirección de email** (3/min) con un techo global por hora (60) detrás. **`consent` es obligatorio.** Son datos personales de alguien que no es usuario de este sistema, así que el consentimiento viaja en la petición en vez de darse por supuesto. Si el formulario del frontend no lo envía, la API responde 422. El buzón lleva permiso propio (`applications.*`) y **el rol `viewer` no lo tiene**: leer el contenido de la web y leer los datos de contacto de quien escribe son dos cosas distintas a propósito. De la IP solo se guarda un HMAC con la clave de la aplicación, suficiente para reconocer una avalancha y no para convertir la tabla en un registro de visitantes. Si un día se llena de spam, `SITE_ACCEPT_SUBMISSIONS=false` cierra los formularios sin desplegar; `/site` devuelve `submissions_enabled: false` para que el frontend los oculte en vez de enseñar un formulario que sería rechazado. ### Preview de borradores ```bash curl -H "X-Preview-Token: $SITE_PREVIEW_TOKEN" \ https://admin.example/api/public/v1/projects/mi-borrador ``` El frontend reenvía el secreto desde su servidor, así que nunca llega al navegador. Las respuestas de preview se marcan `no-store`. Un `SITE_PREVIEW_TOKEN` vacío **no** concede acceso a nadie. Esto **da por supuesto el renderizado en servidor**: es la única forma de que el secreto viaje sin acabar en el navegador de cualquiera. Un frontend que renderizara en cliente necesitaría otra cosa —un token de preview por sesión y con caducidad—, no este secreto compartido. ## Etiquetas y firma del blog Las entradas del blog (páginas de la categoría `blog`) llevan **etiquetas** —«angular», «accesibilidad»— con nombre y slug en cada idioma, que se eligen en el formulario de la página y se crean desde ahí mismo si no existen (Contenido → Etiquetas para ordenarlas; las gestiona quien gestiona las categorías). Y una **firma**: el miembro que la firma en la web, con enlace a su perfil, que no tiene por qué ser la cuenta que la escribió en el panel (`author_id`, que se queda dentro). La API pública manda `tags` (solo las que tienen nombre en ese idioma) y `byline` (solo si el miembro tiene perfil público), acepta `?tag=` en `/public/v1/pages` y tiene `/public/v1/tags` con cuántas entradas publicadas lleva cada una. La de administración, `tag_ids` y `byline_member_id` en las páginas y `/api/v1/tags`. ## La vitrina: servicios, clientes, testimonios y charlas Cuatro tipos de contenido con los que se compone una landing o un portfolio. **No son bloques de portada**: aquí no se decide qué secciones hay ni en qué orden. Cada uno tiene su endpoint y el frontend decide qué monta. ``` GET /api/public/v1/services[/{slug}] GET /api/public/v1/clients GET /api/public/v1/testimonials?client=…&service=… GET /api/public/v1/talks ``` **Servicios** — el reverso de un proyecto: uno dice lo que se ha hecho, el otro lo que se puede pedir. Lo que lo hace algo más que una tarjeta bonita es la relación con proyectos: **un servicio que no puede señalar trabajo real es una promesa**, y el panel lo marca en naranja cuando lleva cero. Tienen slug por idioma, `alternates` y entran en `/slugs`, así que el frontend puede darles página propia. **Clientes** — antes eran dos campos de texto (`beneficiary`, `beneficiary_url`) escritos a mano en cada proyecto: la misma asociación tecleada dos veces eran dos entidades distintas y el logo no tenía dónde vivir. Ahora es una tabla, la migración convierte sola lo que ya había, y el muro de logos sale gratis. Con `is_public`, porque salir en la web de otro es decisión suya, y sin proyectos publicados detrás no aparece: sería un logo que la web no puede justificar. **Testimonios** — cuelgan de un cliente, un proyecto o un servicio, **no de la portada**. Así cada página pide los suyos en vez de haber una lista suelta que alguien tiene que recordar actualizar. Y llevan `original_locale`: **traducir una cita es ponerle palabras en la boca a alguien**. La API manda `is_original: false` cuando lo que estás leyendo lo hemos traducido nosotros, para que la web pueda decir «traducido del castellano» en vez de fingir que lo dijo así. **Charlas** — el contenido más barato de mantener y el único que demuestra que el colectivo existe fuera de su propia web. El título **no se traduce**: es el nombre de una charla que se dio una vez y en un idioma; traducirlo inventaría una charla que no existió. Solo se traduce el resumen escrito después. Se escriben una vez y salen a la vez en la landing y en el perfil de quien la dio. ## Publicación programada y caducidad Dos fechas por registro: cuándo entra y cuándo sale. ``` published_at con fecha futura, no sale en la web hasta que llegue unpublish_at al llegar, desaparece sola ``` **Lo hacen las consultas, no un job.** Los scopes `published()` leen las dos fechas, así que la ventana se respeta esté corriendo lo que esté corriendo: un worker caído no puede devolver a la web el aviso del mes pasado. El scheduler no es lo que la hace funcionar, es solo lo que mantiene honesto lo que el panel dice. Porque ese es el otro problema: **un panel que pone «Publicada» sobre algo que la web no está enseñando le está mintiendo a quien lo escribió**. Ahora la columna de estado dice «Programada · dentro de 3 días» o «Caducada · el 12 de mayo», con filtros para las dos, y avisa de lo que está a punto de irse. ```bash php artisan content:schedule # cierra convocatorias vencidas php artisan content:schedule --dry-run # dice qué haría ``` El scheduler lo ejecuta cada cinco minutos. Cierra las convocatorias cuya fecha pasó —ya no salían en la web, pero seguían inflando el contador de la barra lateral, y un contador que miente deja de leerse— y registra en el log lo que está esperando fecha o lo que acaba de caducar. **Lo que a propósito no hace:** tocar el estado de páginas y proyectos. Su ventana ya las oculta, y reescribir un estado tiraría el que alguien eligió para grabar algo que las fechas ya dicen. En un proyecto, además, el estado es su ciclo de vida («en marcha», «en pausa»), que no es lo mismo que si se ve o no: por eso la visibilidad tiene columna propia en la tabla en vez de pisar la otra. `unpublish_at` viaja también en la API pública, para que una caché de más abajo pueda caducar a la vez que el contenido en vez de quedarse con un aviso que ya no es cierto. ## Redirecciones Cambiar un slug rompe todos los enlaces compartidos, todos los marcadores y todos los resultados de búsqueda — **en silencio**, porque aquí no se entera nadie y la web solo lo descubre cuando un visitante recibe un 404. El momento en que el slug cambia es el único en que la dirección vieja todavía se conoce, así que es cuando se apunta: ``` GET /api/public/v1/redirects todas las reglas activas, de una vez ``` Se crean solas para páginas, proyectos, convocatorias y servicios, **una por idioma** —la URL catalana tiene otro prefijo y otro segmento, así que una regla nunca podría cubrir las dos—. Y no encadenan: si algo se renombra dos veces, las dos direcciones viejas apuntan a la actual, no una a otra. Renombrar de vuelta al slug original borra la regla en vez de crear un bucle. En **Administración → Redirecciones** se pueden añadir a mano las de enlaces anteriores a esta web. La columna de usos dice si alguien sigue viniendo por ahí, que es lo que decide si una regla se puede borrar algún día. Dos comprobaciones vigilan lo que el propio panel escribe: una regla que **apunta a sí misma o a otra regla** (el navegador da vueltas o paga saltos de más) y una que **lleva a una página que ya no existe**, que es peor que el 404 que venía a evitar — pasa cuando se despublica lo que motivó la regla, que es algo perfectamente normal y no deja rastro en ningún otro sitio. ### URLs que nadie encuentra Todo lo anterior tiene un punto ciego: **las redirecciones solo se escriben desde este lado**. Cubren los slugs que se renombran aquí, que es de lo único que esta aplicación se entera. Los enlaces de la web anterior, una dirección impresa en un papel, el enlace que otro sitio puso hace tres años a una página que ya se movió — todo eso falla en la web, delante de un visitante, y aquí no se sabe nunca. Así que lo cuenta el frontend: ``` POST /api/public/v1/telemetry/not-found lo que no ha sabido resolver ``` ```bash curl -X POST https://admin.example/api/public/v1/telemetry/not-found \ -H "X-Ingest-Token: $SITE_INGEST_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"path":"/proyectos/nombre-viejo","locale":"es", "referrer":"https://otro-sitio.example/articulo"}' ``` Aparecen en **Administración → URLs no encontradas**, ordenadas por número de visitas, y cada fila tiene el único botón que importa: **crear la redirección**, con la dirección rota ya puesta. Solo hay que decir a dónde debería llevar. La regla se escribe en la lista de al lado y la entrada deja de estar pendiente. Seis decisiones que conviene conocer: **Lleva token, a diferencia del resto de la API pública.** Los endpoints de lectura no lo necesitan porque devuelven lo que ya está publicado, y el buzón lo escribe un visitante rellenando un formulario. Este lo llama el frontend en su propio nombre, así que puede guardar un secreto — y un endpoint de escritura sin autenticar es una forma de llenar una tabla con lo que a cualquiera se le ocurra. `SITE_INGEST_TOKEN` vacío **desactiva** el endpoint; no lo abre. **Es un contador, no un registro.** Una fila por (dirección, idioma) con el número de visitas. Un rastreador recorriendo una sección muerta cuesta una fila, no una por petición, y ese número es justo lo que decide si una dirección merece redirección. Acepta lotes de hasta 50. **Las direcciones se normalizan igual que en las redirecciones.** `/algo/` y `/algo?utm_source=x` son la misma dirección rota. Si las dos normalizaciones se separaran, la lista se llenaría de direcciones que ya tienen regla. **Se tira lo que nunca estuvo aquí.** `/wp-admin`, `/.env`, cualquier cosa acabada en `.php`: casi todo lo que se le pide a una web pública es alguien buscando el panel de otro, y una lista donde los enlaces rotos de verdad están enterrados debajo de eso es una lista que no abre nadie. La respuesta dice cuántos reportes se guardaron de los que llegaron, para que el frontend pueda comparar su filtro con este. **La procedencia es la columna que más dice.** Si el `referrer` es de nuestro propio dominio, no es el marcador viejo de alguien: es que **algo que publicamos enlaza a una dirección que no existe**. El panel lo marca en rojo y el check de salud lo sube de aviso a fallo. **Ignorar y resolver no son lo mismo cuando la dirección vuelve.** Una resuelta que se vuelve a pedir regresa a pendiente —la redirección se borró, o nunca llegó a funcionar—; una ignorada se queda ignorada por muchas visitas que reciba, porque si no el botón no serviría dos veces. El check **URLs que nadie encuentra** cuenta las pendientes con más de una visita: una sola petición es tan probable que sea un escáner como una persona, y un número que no llega nunca a cero deja de leerse. `content:prune` se lleva las de una sola visita que llevan 30 días sin repetirse; las ignoradas y las resueltas se quedan, porque olvidarlas traería la misma decisión otra vez la semana siguiente. ### Qué se lee de verdad El panel siempre ha sabido qué existe y qué está publicado. Nunca ha sabido si alguien lo abre — y esa es la mitad que falta de casi cualquier decisión editorial: **qué merece traducirse primero**, si el proyecto destacado lo mira alguien, si una página compensa el trabajo de mantenerla al día. ``` POST /api/public/v1/telemetry/views lo que la web ha mostrado ``` ```bash curl -X POST https://admin.example/api/public/v1/telemetry/views \ -H "X-Ingest-Token: $SITE_INGEST_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"views":[{"type":"project","slug":"mi-web","locale":"es","count":12}, {"type":"page","slug":"quienes-somos","locale":"ca"}]}' ``` **Esto no es analítica, y la diferencia importa.** No hay visitante aquí ni forma de construir uno: ni IP, ni sesión, ni user agent, ni referrer, ni hora del día. Lo que se guarda es **una fila por registro, idioma y día con un número** — justo lo que hacen falta para las preguntas de arriba y nada que se pueda volver a convertir en una persona. La tabla no tiene dónde meterlo, y hay un test que lo comprueba. Cuatro decisiones más: **Se reporta tipo y slug, no una ruta.** La web pidió `/public/v1/projects/mi-web` para pintar la página, así que ya sabe qué era; no tiene que volver a parsear su propia URL. Y así un reporte solo puede nombrar algo que esta API sirve. **Cada idioma cuenta aparte.** Saber en qué idioma se lee algo es la mitad del valor: es lo que dice si la traducción catalana la usa alguien. **Un slug que ya no resuelve no cuenta.** Eso es un 404, y para eso está el endpoint de al lado. Sumarlo aquí sería atribuir una lectura a nada. **El `count` está acotado a 1000 por reporte.** La web cuenta sus propios renders; no le corresponde inventarse una audiencia. En el panel salen como columna **Visitas** (30 días, sumando idiomas) en páginas, proyectos y servicios, con filtro **«Publicado y sin visitas»** — que es la pregunta accionable— y un widget de **lo más leído** en el dashboard, que no aparece hasta que hay algo que enseñar: un widget con tres ceros enseña a ignorar esa esquina de la pantalla. La columna está **vacía**, no a cero, cuando no hay datos: mientras el frontend no reporte, un cero sería mentira. `content:prune` conserva 400 días (`--views-days`), que da para comparar con la primavera pasada. ## Traducciones que se quedaron atrás El check de traducciones que faltan encuentra el agujero: publicado en castellano, ausente en catalán. **No encuentra el caso que de verdad pasa**: la traducción existe —así que ninguna pantalla se queja y el registro cuenta como terminado— y lleva dos meses diciendo algo que el original dejó de decir. Una traducción desactualizada es peor que una que falta, porque la que falta al menos es honesta. No hace falta ninguna columna nueva para saberlo: **el historial ya lo sabe**. Cada revisión guarda el mapa de idiomas entero del campo que tocó, antes y después, así que «cuándo cambió por última vez el cuerpo en castellano y cuándo en catalán» es una pregunta que ya se puede responder. Una columna sería una segunda copia de eso, que alguien tendría que mantener a mano. Se ve donde ya se miraba, en la columna de idiomas: ``` ES EN CA las tres al día ES EN ca catalán sin traducir ES EN CA! catalán traducido y desactualizado ``` Con filtro **«Traducción desactualizada»** en páginas, proyectos y servicios, y un check de salud que dice desde cuándo lleva a la deriva cada idioma —«desde marzo» es lo que decide si corre prisa. Tres decisiones: **El slug no cuenta.** Renombrar la dirección castellana no hace menos cierto el texto catalán, y es el único campo traducible que cambia solo constantemente: contarlo marcaría media web como desactualizada la primera vez que alguien ordena una URL. **Un idioma vacío no está desactualizado, está sin traducir**, y de eso ya informa el otro check. Decir las dos cosas del mismo registro sería tener dos listas que no se vacían nunca. **Un registro que nadie ha editado nunca está al día**, se escribiera en uno o en tres idiomas: si llegaron juntos, ninguno va por detrás de otro. El cálculo se cachea contra el `updated_at` del registro, así que se rehace en cuanto algo cambia y ni un minuto antes. ### El asistente de traducción Contenido → Traducciones lista lo que falta escribir en un idioma (azul) y lo que se quedó atrás (ámbar, con desde cuándo), con un botón por idioma. Desde la edición de una página, proyecto, servicio o convocatoria, «Traducir» lleva al mismo sitio. Al abrir uno, cada campo traducible sale **con el original al lado** y el campo a escribir enfrente (texto enriquecido donde lo es). Y si la traducción se quedó atrás, ese campo dice desde cuándo y enseña **qué cambió en el original desde que se tradujo**, palabra a palabra: lo que se quitó tachado y lo que entró marcado. El texto «de entonces» sale del historial, como la fecha. Así quien traduce rehace la frase que cambió en vez de releerlo todo. Solo se guarda el idioma que se está traduciendo, y el slug se deja al formulario de la ficha: una dirección no es una traducción. ## Revisión `pages.publish` lleva separado desde el principio: **poner algo en la web es un acto mayor que escribirlo**. Lo que faltaba era la otra mitad. Quien tenía `pages.update` y no `publish` podía terminar un texto y **no tenía forma de decir que estaba listo** — el campo de estado que lo diría está deshabilitado justo para esos usuarios. El traspaso ocurría por chat, o no ocurría. Ahora son dos botones, en páginas, proyectos y servicios: **Pedir revisión** — para quien no puede publicar. Opcionalmente se lo pide a alguien concreto; si no, lo ve todo el que pueda publicarlo. Puede dejar una nota. No hace falta ningún permiso nuevo: **poder editar algo ya es el derecho a decir que has terminado con ello**. **Devolver** — para quien sí puede publicar, cuando todavía no. El motivo es **obligatorio**: devolver el trabajo de alguien sin decir por qué es lo que hace que la gente deje de usar la revisión y publique por su cuenta. No hay botón de «aprobar», porque ya existía: **publicar es la aprobación**. Al publicar se cierra la revisión sola, venga del formulario, del botón de la tabla o de donde sea — está enganchado al estado y no al botón, así que nada puede dejar algo en la web y a la vez marcado como pendiente. Programar para dentro de una semana **no** la cierra: la web todavía no lo enseña. La pestaña **Revisión** de cada ficha guarda la conversación: qué se pidió, qué se devolvió, por qué, y quién publicó al final. Es lo que el historial de cambios no puede guardar — una revisión sabe que el título antes decía otra cosa, pero no puede saber que esto volvió porque falta la foto, que es la frase que nadie escribió en ningún campo. Alrededor de eso: - **La campana** avisa a quien puede publicarlo. Al devolver, avisa a quien lo pidió y **solo** a esa persona: una cola que anuncia cada decisión a todo el mundo es una campana que se deja de leer. - **El dashboard** dice cuántas cosas esperan tu revisión. La campana se marca como leída y desaparece; esto no. - **Filtro «Esperando mi revisión»** en las tres tablas. Las peticiones sin destinatario salen en la de todos: una revisión que no se pidió a nadie es de todos, que sigue siendo mejor que una dirigida a quien el autor adivinó que estaba libre. - **Check de salud** para lo que lleva más de una semana esperando. Es el fallo que introduce este paso: antes, quien no podía publicar estaba atascado y lo sabía; ahora está esperando y cree que algo está pasando. **Solo en páginas, proyectos y servicios**, que son los tres con permiso de publicación propio. Añadir revisión a un testimonio sería ceremonia: quien puede escribirlo puede publicarlo, así que no hay a quién preguntar. > **Esto solo se activa para quien pueda editar y no publicar**, que es el rol > **writer**. `editor` tiene todas las habilidades de contenido, `publish` > incluida, así que a un editor los botones no le salen: no los necesita. > Después de actualizar, `php artisan permissions:sync` crea el rol nuevo. ### En lote Los dos botones de revisión existen también para una selección entera: **Pedir revisión** y **Publicar**, en la barra de acciones en lote de páginas, proyectos y servicios. Ninguno de los dos trata la selección como un bloque. **Publicar** salta lo que ya está publicado; **Pedir revisión** salta lo que ya espera revisión o ya está publicado — una selección de veinte filas en estados mezclados es el caso normal, no uno raro que haya que evitar. El aviso final dice cuántas se tocaron de verdad, no cuántas se marcaron. Los registros se guardan **uno a uno, no en una sola consulta**: publicar dispara el cierre de la revisión pendiente y la entrada en el historial de cambios, y una única `UPDATE` sobre toda la selección se saltaría las dos cosas sin avisar — el mismo motivo por el que `content:prune` ya recorre la papelera registro a registro en vez de en bloque. La visibilidad de los botones se decide **una vez, por el permiso** (`pages.publish`, no una comprobación registro a registro): a diferencia de `update`, que en páginas tiene una excepción por autoría, publicar en lote no tiene ese matiz, y preguntárselo a la política de un modelo sin un registro concreto no tiene sentido. ## Ediciones simultáneas Dos personas pueden abrir la misma ficha a la vez, y hasta ahora el panel no tenía ni idea. Quien guardaba segundo pisaba a quien guardaba primero **sin avisar a nadie**: sin error, sin rastro más allá de una revisión que parece una edición cualquiera. Dos avisos, uno por cada momento en que la colisión se puede notar: **Al abrir la ficha**, si alguien más la tiene abierta ahora mismo, se dice quién. Solo se avisa a quien llega segundo — quien ya estaba no tiene por qué esperar compañía, y no hay nada que este aviso pudiera decirle que le sirviera de algo. **Al guardar**, si el registro cambió por debajo mientras se editaba, **se rechaza el guardado**. Este es el que de verdad evita perder trabajo, y no depende de que el aviso anterior se viera ni de que llegara a mostrarse: una pestaña cerrada, un segundo monitor, alguien que guardó hace diez minutos, todo acaba atrapado igual, porque se comprueba la base de datos en el momento de guardar, no lo que decía un aviso anterior. Cuando el historial de la ficha sabe quién fue, el mensaje lo nombra. ``` Alguien más lo tiene abierto al entrar, si aplica No se ha guardado — María lo cambió al guardar, si aplica ``` Aplicado a **las dieciséis pantallas de edición del panel**, no elegido recurso por recurso: una colisión es cosa de dos personas y un registro, no de qué sea el registro. Tres decisiones: **Quién ha «entrado» se olvida solo a los 10 minutos** sin interacción. No hay forma de saber que alguien cerró la pestaña, así que la presencia es en realidad «esto se abrió hace poco y alguien sigue tecleando en ello» — la forma correcta de plantear el problema: importa que dos personas estén editando a la vez, no un recuento exacto. **La comprobación es de segundo entero**, porque así se guarda `updated_at` aquí. Dos guardados dentro del mismo segundo son una carrera real incluso en producción; el aviso es honesto en que no la detecta. **El valor de referencia viaja bloqueado.** Livewire solo conserva entre peticiones las propiedades públicas de un componente — una protegida se reiniciaría a `null` en cada guardado y desactivaría el aviso en silencio, que es exactamente el fallo que apareció al escribir esto—, y pública a secas podría venir manipulada por la propia petición. Va marcada `#[Locked]`, igual que el registro que edita. ## Historial de cambios Cada modelo de contenido guarda quién cambió qué, con el valor de antes al lado del de después. Solo lo que cambió: guardar registros enteros haría el historial más grande que el contenido que describe. Lo que más se echaba en falta es **quién publicó**. `pages.publish` existe como permiso aparte precisamente porque poner algo en la web es un acto mayor que escribirlo, y no quedaba constancia; ahora publicar y despublicar salen con nombre propio en la lista, distintos de un «editado» cualquiera. Se ve en la pestaña **Historial** de cada ficha, con el diff campo por campo y un botón para volver a esos valores. Restaurar también queda registrado, así que nunca se pierde el camino de vuelta. Un cambio hecho por el scheduler o por un comando se registra **sin usuario** y lo dice, en vez de echarle la culpa al último que entró. ## Salud del sitio La web está en otro repositorio y en otra máquina, así que sus fallos no se ven editando: se ven visitándola, normalmente tarde y normalmente los ve un visitante. **Administración → Salud del sitio** los junta en una página, con un contador en la barra lateral. Trece comprobaciones, ordenadas por gravedad: | | Comprueba | Por qué importa | |---|---|---| | 🔴 | Slugs que faltan | Traducido a un idioma sin slug en él: esa URL no existe o da 404, y el `hreflang` sale mal. | | 🔴 | Slugs repetidos | Dos registros en la misma URL. La regla lo impide al escribir, pero un slug vive dentro de un JSON y **nunca puede ser una restricción de base de datos**: un seeder o una importación se cuelan. | | 🔴 | Imágenes rotas | El archivo ya no está en disco. La API sigue publicando la URL porque no tiene motivo para dudar. | | 🔴 | Gente esperando respuesta | Alguien escribió hace más de una semana y sigue sin contestación. | | 🔴 | URLs que nadie encuentra | Direcciones que dan 404 y se piden más de una vez. En rojo cuando el enlace roto sale de nuestra propia web; en amarillo cuando es de fuera. | | 🟡 | Contenido sin traducir | Publicado pero ausente en algún idioma. | | 🟡 | Traducciones desactualizadas | El original cambió después de traducirse. La traducción existe, así que nada más lo avisa. | | 🟡 | Imágenes sin describir | Sin `alt`. La API manda `alt: ""`, que es la forma correcta de decir «decorativa», así que el frontend no distingue el blanco deliberado del olvidado. Esto sí. | | 🟡 | Fichas incompletas | Sin meta descripción o sin portada: nada que enseñar en un buscador ni al pegar el enlace en un chat. | | 🟡 | Revisiones sin contestar | Alguien pidió revisión hace más de una semana. Antes estaba atascado y lo sabía; ahora espera y cree que algo está pasando. | | 🟡 | Convocatorias vencidas | Abiertas con la fecha pasada. Ya no salen en la web, pero inflan el contador del panel. | | 🟡 | Imágenes sin procesar | Más de una hora en cola: no hay worker ni scheduler. | | 🟡 | Repositorios que no sincronizan | La web sigue enseñando los últimos números, que ya no son ciertos. | Cada resultado trae **ejemplos, no solo un número**: «12 páginas» es una estadística, «12, empezando por estas» es algo sobre lo que actuar. **Los checks se ocultan según permisos.** Contar solicitudes es saber que existen, así que quién puede verlo es la misma pregunta que quién puede leer el buzón; el `viewer` no ve ese check ni el de repositorios. El comando, en cambio, los ejecuta todos: un despliegue no es una persona a quien esconderle nada. ```bash php artisan site:check # falla si hay avisos o fallos php artisan site:check --fail-on=error # falla solo con lo que rompe la web php artisan site:check --fail-on=never # solo informa php artisan site:check --json ``` Sale con código distinto de cero, así que un despliegue puede negarse a terminar sobre una web que ya está rota. **El contador de la barra lateral está cacheado cinco minutos**, porque se dibuja en cada página del panel y ejecutar diez comprobaciones para pintar un número haría el panel entero más lento. Abrir la página lo invalida: es el momento en que sí queremos respuestas frescas. Añadir una comprobación es escribir una clase que implemente [`Check`](app/Support/Health/Check.php) y listarla en `HealthReport::CHECKS`. ### Herramientas del día a día **Dashboard** — arriba del todo, lo que está esperando a alguien: gente sin respuesta, contenido que caduca, servicios sin obra detrás. Son las mismas comprobaciones de salud en el sitio donde todo el mundo aterriza, en lugar de contadores que no cambian nunca. **Papelera** (Administración → Papelera) — todo lo borrado junto, con el botón de deshacer al lado. Antes recuperar algo exigía acordarse de en qué recurso estaba y activar un filtro, que es mucho pedirle a quien acaba de borrar lo que no era. **Vista previa** — un enlace firmado por registro y por idioma, que caduca en 8 horas y **solo abre ese registro**. El `SITE_PREVIEW_TOKEN` compartido sigue funcionando, pero es un secreto único que hay que pegar en un curl; esto se puede mandar por chat sin dar acceso a todos los borradores del sitio. **Duplicar** — en páginas, proyectos, servicios y convocatorias, en la tabla y en el formulario, y por API (`POST /api/v1/{tipo}/{id}/duplicate`). La copia es un **borrador**: mismo contenido, misma tecnología, mismo equipo con sus roles, pero sin publicar, fuera de cualquier revisión, con el título marcado «(copia)» en cada idioma y un slug libre (`hola-copia`, `hola-copia-2`…). Se abre en cuanto se crea. Antes la copia salía sin slug —un registro sin dirección, que la salud del sitio marcaba— y conservaba el estado: duplicar una página publicada daba otra «publicada» sin URL. **Responder al buzón** — en la ficha de cada solicitud, «Responder» abre un mensaje con el asunto ya puesto y, si se quiere, una **plantilla** (Colectivo → Plantillas de respuesta) rellenada en el idioma en que escribió esa persona: `{name}`, `{subject}` y `{sender}` se sustituyen. Se edita y se envía. Si la instalación tiene un mailer que entrega de verdad (no `log` ni `array`), sale desde la aplicación; si no, se abre el programa de correo de quien responde con el texto ya escrito. En los dos casos **queda registrada** en la ficha —quién, cuándo, qué— para que la siguiente persona que la abra sepa qué se dijo, y una solicitud nueva pasa a «contactada» (una ya aceptada o rechazada no cambia). El botón de siempre, un correo en blanco, sigue al lado. Por API: `GET /api/v1/applications/{id}/replies`, `POST /api/v1/applications/{id}/reply` (con `template_id`, o `subject` y `body`) y `GET /api/v1/reply-templates`. `MAIL_REPLY_TO_ADDRESS` decide a dónde vuelven las contestaciones. **Calendario editorial** (Contenido → Calendario editorial) — un mes de un vistazo: lo que se publicó o se publicará (programado), lo que sale de la web, y las convocatorias que abren y cierran, cada cosa enlazada a su formulario. Las fechas ya estaban, de una en una en cada ficha; juntas es donde se ve que tres cosas salen el mismo lunes y nada en quince días. El mes va en la URL (`?month=2026-10`) para poder mandarlo, y en el móvil pasa a una lista con solo los días que tienen algo. Cada persona ve los tipos que puede listar. **Estadísticas** (Contenido → Estadísticas) — de las lecturas que ya manda el frontend (totales por día, registro e idioma; nada sobre la persona): lo más leído, las lecturas de cada idioma con su parte del total y una gráfica diaria, y lo que **está en la web y nadie abrió** en el periodo —o cuesta encontrarlo o no compensa—. 7, 30 o 90 días, y cada cifra comparada con el periodo anterior de la misma duración, porque «320 lecturas» dice poco y «320, desde 210» dice si algo funcionó. **Próximos vencimientos** — en el dashboard, las convocatorias que cierran y el contenido que sale de la web solo (`unpublish_at`) en las próximas dos semanas, a quien puede editarlos. Y `content:expiring`, cada día a las 8, avisa en la campana del panel a esas mismas personas **una semana y un día antes** (`site.expiry_reminders`): dos avisos por cosa, no uno cada mañana durante quince días. Nada de esto es un error —las fechas se pusieron a propósito— y por eso no está en la salud del sitio; pero una convocatoria que cierra el viernes sin que nadie haya contestado al buzón se ve mejor con una semana de margen. **Vista previa en el formulario** — el mismo botón de vista previa está también arriba de la edición de páginas, proyectos, servicios y convocatorias: se mira en la web real sin salir de lo que se está escribiendo. **Búsqueda global** — los doce recursos, desde la barra de arriba. ### Versión de contenido `/site` devuelve `content_version`, un número que sube cuando cambia algo **publicado**. Úsalo como parte de la clave de caché del frontend y todo lo que hay por debajo caduca en el momento en que la web cambió de verdad, en vez de cuando lo dice un temporizador. Sin webhook de purga, sin secreto compartido y sin nada que desplegar. Editar un borrador no lo mueve: nadie lo está viendo. Despublicar sí, porque desaparecer es tan visible como aparecer. ### Limpieza Cinco cosas se acumulan para siempre y ninguna se ve: el historial, la papelera, los archivos de imagen cuyo registro ya no existe y las dos que reporta el frontend, los 404 y las cifras de lectura. Por separado son insignificantes; en un par de años son la mayor parte de la base de datos y del disco. ```bash php artisan content:prune --dry-run # dice qué se iría php artisan content:prune # historial > 1 año y papelera > 90 días php artisan content:prune --media # además, imágenes que no usa nadie ``` Lo que reporta el frontend —los 404 y las cifras de lectura— es lo único que crece sin que nadie del colectivo haga nada, así que se limpia por defecto en vez de detrás de un flag: de los 404 solo se van los de una sola visita que llevan 30 días sin repetirse (`--missing-days`), y de las lecturas lo anterior a 400 días (`--views-days`). Es deliberadamente conservador: **nunca corre solo** —no está en el scheduler—, las ventanas son generosas, y del historial de cada registro **siempre se conservan la primera y la última entrada**, que son los dos datos que alguien pregunta años después: cuándo apareció y quién lo tocó por última vez. Los archivos van aparte con `--media` porque borrar uno es lo único aquí que no se arregla restaurando una fila. Una imagen de algo que está en la papelera **no se toca**: se puede recuperar, y recuperarlo con la imagen rota sería peor. ## Modo mantenimiento Pone en mantenimiento **solo la web pública**. Deliberadamente *no* es `artisan down`, que tumbaría también el panel justo cuando hace falta para arreglar lo que se ha roto. Mientras está activo: - La API pública responde **503** con el aviso traducido, `Retry-After` y `Cache-Control: no-store` (un 503 cacheado sobreviviría al mantenimiento). - El panel y la API de administración **siguen funcionando**: se puede seguir editando y los repositorios se siguen sincronizando. - El equipo ve la web real usando el token de preview o desde una IP de `SITE_TRUSTED_IPS`, así que se puede comprobar el arreglo antes de levantarlo. Desde el panel: **Administración → Modo mantenimiento**, con el aviso editable en los tres idiomas y una hora de levantado automático opcional (para que un mantenimiento olvidado no deje la web caída indefinidamente). Mientras está activo, la barra lateral lo marca en rojo. Desde la CLI, para scripts de despliegue: ```bash php artisan site:maintenance --message="Volvemos en un rato" --until="+2 hours" php artisan site:maintenance --status php artisan site:maintenance --up ``` El cuerpo del 503 es lo que el frontend renderiza como página de mantenimiento: ```json { "message": "Estamos migrando el Gitea. Volvemos en un rato.", "maintenance": true, "locale": "es", "since": "2026-08-13T10:01:13+00:00", "until": "2026-08-13T12:01:13+00:00" } ``` Activarlo es un permiso propio (`settings.maintenance`), separado de editar ajustes: los editores ven el estado pero no pueden tumbar la web. ## API REST de administración Documentada junto con la pública en [`public/openapi.yaml`](public/openapi.yaml) (OpenAPI 3.0), navegable en **`/docs/api`**. ``` POST /api/v1/auth/token canjear credenciales por un token GET /api/v1/me quién soy + mis permisos efectivos GET /api/v1/pages filtros: status, category_id, search, translated_in, trashed, sort, per_page POST /api/v1/pages GET /api/v1/pages/{page} PATCH /api/v1/pages/{page} DELETE /api/v1/pages/{page} POST /api/v1/pages/{page}/restore /api/v1/categories CRUD completo GET /api/v1/users PATCH para roles, is_active, locale GET /api/v1/roles solo lectura GET /api/v1/roles/catalogue catálogo de permisos agrupado /api/v1/projects CRUD + restore; relaciones por ids: technology_ids, service_ids, members[{id, role}] /api/v1/members CRUD + restore; links (etiqueta → URL), technology_ids /api/v1/services CRUD + restore; technology_ids, project_ids /api/v1/technologies CRUD completo; filtros: category, radar_ring (none = fuera del radar), search /api/v1/clients CRUD + restore; filtros: is_public, search /api/v1/testimonials CRUD + restore; filtros: is_public, client_id, project_id, service_id, search /api/v1/talks CRUD (sin papelera, como en el panel) /api/v1/calls CRUD; technology_ids; filtros: status, project_id, search GET /api/v1/applications el buzón; filtros: status (sin él, sin spam), kind, call_id, search, trashed PATCH /api/v1/applications/{id} solo status y notes DELETE /api/v1/applications/{id} + POST …/restore /api/v1/redirects CRUD; from/to normalizados, únicos por idioma GET /api/v1/missing-urls las 404 que avisó el frontend (pending por defecto) PATCH /api/v1/missing-urls/{id} pending ⇄ ignored POST /api/v1/missing-urls/{id}/redirect crear la redirección que la resuelve /api/v1/repositories CRUD (solo lo que no viene del proveedor) POST /api/v1/repositories/{id}/sync encola una sincronización (202) GET /api/v1/settings ajustes; PATCH /settings/{clave} sustituye el valor GET /api/v1/maintenance modo mantenimiento de la web pública PUT /api/v1/maintenance {enabled, message, until} POST /api/v1/media subir una imagen (fichero o URL) GET /api/v1/media la biblioteca; filtros: for, mime, search, pending, failed, orphans; cada una dice si algo la usa (referenced) DELETE /api/v1/media/{id} solo si nada la usa (409 si no), con sus versiones POST /api/v1/media/{id}/reprocess volver a derivar versiones (cola) ``` Publicar sigue siendo un permiso aparte, como en el panel (donde esos campos salen desactivados): sin `projects.publish` o `services.publish`, la API rechaza `published_at`, `unpublish_at` e `is_published`, así que quien puede escribir algo no puede sacarlo a la web solo. Las relaciones enviadas sustituyen a las que había, y las no enviadas no se tocan; todo se guarda en una transacción. ### Historial, revisión y vista previa ``` GET /api/v1/{tipo}/{id}/revisions historial (páginas, categorías, proyectos, miembros, servicios, clientes, testimonios, charlas, tecnologías, convocatorias) POST /api/v1/revisions/{id}/restore deshacer lo que cambió una revisión GET /api/v1/reviews lo que espera mi revisión GET /api/v1/{tipo}/{id}/reviews el hilo (pages, projects, services) POST /api/v1/{tipo}/{id}/comments comentar POST /api/v1/{tipo}/{id}/review pedir revisión {reviewer_id?, note?} POST /api/v1/{tipo}/{id}/return devolver con motivo {note} POST /api/v1/{tipo}/{id}/publish aprobar: ya, o programado con {at} POST /api/v1/{tipo}/{id}/unpublish POST /api/v1/{tipo}/{id}/preview enlaces de vista previa por idioma GET /api/v1/health la pantalla de salud del sitio GET /api/v1/translations/pending lo que falta traducir (missing) o se quedó atrás (stale); filtros: locale, type, published ``` Lo mismo que los botones del panel, con las mismas reglas: pedir revisión es para quien escribe sin `{tipo}.publish`, devolver y publicar son para quien lo tiene, y deshacer una revisión que tocó la publicación también lo pide. Programar una publicación responde la revisión en ese momento, no cuando llega la fecha: la decisión ya está tomada. Los enlaces de vista previa son los del panel —un registro, un idioma, unas horas— y [`AdminContent`](app/Support/AdminContent.php) es la tabla de qué tipo admite cada cosa, la que leen las rutas, los controladores y el historial. El historial ahora llama «publicado» solo a lo que publica de verdad: cambiar el `status` de un proyecto (activo, en pausa) salía como publicación, y activar un servicio no. Los ajustes siguen los permisos del panel: `settings.view` para leer, `settings.update` para cambiar y `settings.maintenance` para `site.maintenance` y `/v1/maintenance`. `content.version` lo escribe la aplicación y la API lo rechaza: puesto a mano, el frontend serviría páginas viejas o las pediría de nuevo para nada. Sincronizar un repositorio pide `repositories.sync`, porque gasta un límite del proveedor que comparte toda la aplicación, y va a la cola en vez de hacer esperar a quien llama. `GET /v1/me` devuelve el array `permissions`: úsalo para construir la UI del cliente en vez de comprobar nombres de rol. Quien no tenga `pages.publish` solo ve páginas publicadas en el listado. ### Tokens para máquinas ``` GET /api/v1/tokens mis tokens (all=1 para un super_admin) POST /api/v1/tokens {name, abilities, expires_at?} → el token, una vez DELETE /api/v1/tokens/{id} revocar ``` Para scripts, CI o integraciones que no deberían usar la contraseña de nadie. Una persona lo crea con los permisos que le da (`pages.update`, o `pages.*` para todo lo de páginas) y nunca más de los que ella tiene; caduca a los 90 días si no se dice otra cosa, y como mucho al año. Se guarda solo su hash, y el token en claro (`pmt_…`) sale una única vez, en la respuesta que lo crea. Actúa como esa persona **dentro** de sus permisos, y se comprueba en cada petición, así que quitarle un rol recorta sus tokens al momento; desactivarla los apaga. El guard lo reconoce por el prefijo y **no lo manda nunca al servidor de identidad**. Un token no puede crear ni revocar tokens: si no, uno filtrado sería una forma de no caducar nunca. Para que eso funcione, la comprobación de permisos es ahora una sola regla en `AuthServiceProvider` y la de spatie está apagada (`register_permission_check_method => false`): la de spatie respondía antes y concedía el permiso sin preguntar al token. ### Webhooks salientes En el panel, **Administración → Webhooks** (solo con `webhooks.*`): alta con la URL y los eventos marcados, y al guardar sale **el secreto una única vez**, en un aviso que no se cierra solo. En la ficha, «Enviar prueba» manda un `ping`, «Cambiar secreto» genera otro (el anterior deja de valer en el acto) y abajo están **los envíos**: estado, respuesta, intentos, error, el contenido enviado y «Reenviar». El menú se marca en rojo si alguno se apagó por fallos. Lo mismo por API: ``` /api/v1/webhooks CRUD (permiso webhooks.*, solo admin) GET /api/v1/webhooks/{id}/deliveries qué se envió y cómo fue POST /api/v1/webhooks/{id}/ping envío de prueba POST /api/v1/webhook-deliveries/{id}/redeliver ``` Avisan a otro sistema de `application.received`, `content.published`, `content.unpublished`, `review.requested` y `review.returned`, y además de **cualquier cambio que haga una persona en una ficha**: `{tipo}.created`, `.updated`, `.deleted` y `.restored` para páginas, categorías, etiquetas, proyectos, miembros, servicios, clientes, testimonios, charlas, tecnologías, convocatorias, redirecciones, repositorios, medios y el buzón. Se puede suscribir a uno, a `*`, a todo un tipo (`projects.*`) o a una acción en cualquiera (`*.deleted`). Esos eventos salen del [registro de auditoría](#registro-de-auditoría), así que dicen lo mismo que él —qué ficha, quién, por dónde (panel, API, token) y, al editar, qué campos cambiaron con su valor anterior y el nuevo— y dejan fuera lo mismo: lo que hace la aplicación sola y los secretos. Del buzón solo se cuenta cómo se está atendiendo, salvo con `include_personal_data`. Usuarios, tokens, webhooks y ajustes no se envían nunca: son la seguridad de la aplicación, no su contenido. Salen de los propios registros —el historial ya distingue publicar de editar, venga del panel, de la API o de un lote— y no de los controladores, así que no hay forma de publicar algo sin que se avise. Cada envío es un POST JSON `{id, event, created_at, data}` firmado: la cabecera `X-Webhook-Signature` es `sha256=` más el HMAC-SHA256 de `{timestamp}.{body}` con el secreto, y `X-Webhook-Timestamp` va aparte para que un envío capturado no se pueda repetir más tarde. El secreto se genera solo (o se rota con `rotate_secret`), se guarda cifrado y sale una única vez. Las mismas defensas que el descargador de imágenes, porque la petición sale desde dentro del stack: solo HTTPS, solo direcciones públicas —comprobado al guardar y **otra vez en cada envío**, fijando la conexión a la dirección comprobada—, sin seguir redirecciones. Cinco reintentos en unas dos horas y media; veinte fallos seguidos lo apagan hasta que alguien lo vuelve a activar. Los datos de quien escribe al buzón (nombre, correo, mensaje) solo viajan a los webhooks con `include_personal_data`. ### Registro de auditoría `GET /api/v1/audit` (permiso `audit.view_any`, solo admin): quién hizo qué, cuándo y **por dónde entró** —`panel`, `api`, `token` (con qué token), `console`— y desde qué IP. Filtros: `user_id`, `action` (exacta o un prefijo como `pages.`), `subject_type`, `subject_id`, `via`, `from`, `to`. El historial de cada ficha contesta «qué le pasó a esto»; esto contesta «qué ha estado haciendo esta persona», en todo, incluido lo que no tiene historial propio: roles (los eventos de spatie están activados para eso), ajustes, tokens, webhooks, medios, el buzón. Lo escribe [`Auditor`](app/Support/Audit/Auditor.php) desde los eventos de los modelos, así que no depende de que cada controlador se acuerde. Solo lo que hace una persona: una sincronización o una publicación programada son la aplicación trabajando y enterrarían lo que se viene a buscar, y el `content.version` que sube con cada edición tampoco cuenta. Los secretos (contraseñas, hashes de token, secretos de webhook) salen como `••••`, y los valores largos se recortan: el detalle completo está en el historial de la ficha. Como nombra a personas y sus direcciones, se borra pasados `AUDIT_RETENTION_DAYS` (365 por defecto) con `model:prune`, programado a diario. ### Lotes e importación ``` POST /api/v1/batch hasta 50 operaciones {method, path, body}; atomic POST /api/v1/import/{tipo} filas JSON (rows) o CSV (file); match, atomic, dry_run ``` Cada operación de un lote pasa por el kernel HTTP como una petición propia con las credenciales de quien llama: se valida, se autoriza, cuenta para el límite de peticiones y responde igual que si llegara sola, así que un lote no puede hacer nada que sus operaciones no pudieran. [`BatchRunner`](app/Support/Batch/BatchRunner.php) no sabe qué es una página, y es justo lo que lo hace seguro. Con `atomic` va en una transacción y se para en el primer fallo deshaciéndolo todo; lo que ya salió de la base (un trabajo en cola, un aviso) no se deshace. La importación convierte filas en esas mismas llamadas: con `id`, o con el campo de `match` (`slug`, `name`, `full_name`) apuntando a una ficha que existe, actualiza; si no, crea. En CSV, `title.es` rellena el título en castellano, una lista se escribe `1|2|3`, `true`/`false`/`null` son lo que dicen y una celda vacía se omite en vez de borrar lo que hubiera. Por defecto es todo o nada, y `dry_run` lo comprueba todo sin guardar nada. ### Búsqueda `GET /api/public/v1/search?q=…` busca en lo que la web enseña —páginas, proyectos y servicios publicados, convocatorias abiertas, miembros públicos y tecnologías en uso— en el idioma de la petición, lo mejor primero, con el `path` de cada resultado en la web. En Postgres es **búsqueda de texto completo**: las palabras se reducen a su raíz en el idioma que se busca («proyectos» encuentra «proyecto»; el catalán, sin diccionario en un Postgres de serie, se compara palabra a palabra), el título pesa más que el cuerpo y las etiquetas del texto enriquecido no cuentan como palabras. En cualquier otra base —el SQLite de los tests— cae a comparar las palabras tal cual: encuentra menos y ordena a grandes rasgos, pero responde con la misma forma. Los tests cubren esa segunda rama; la de Postgres no tiene base contra la que correr en la suite. ## Documentación navegable ``` /docs/api Swagger UI con las dos APIs /openapi.yaml la especificación en crudo /api-types.d.ts los tipos TypeScript, generados de la anterior ``` En el panel está en **Administración → Documentación de la API**, y abre en una pestaña nueva: ir a mirar la referencia no debería tirar por la borda lo que tuvieras a medio escribir. **Sin autenticación, a propósito**: quien más la necesita escribe el frontend y no tiene por qué tener cuenta en el panel, y la especificación describe endpoints, no secretos. Para reservarla al colectivo, envuelve la ruta en el middleware `auth` y saca `openapi.yaml` de `public/`. Swagger UI está **autoalojado** en `public/vendor/swagger-ui/`, no servido desde un CDN: la documentación tiene que funcionar en un portátil sin internet, y una etiqueta `