Files
landing-api/README.md
Josep Piguillem 3c119d425e
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Open a TaskStream board for a project that has none yet
The project form gets «Crear en TaskStream» while the project has no
board, and `taskstream: true` on a PATCH does the same through the API.
A refusal leaves the edit unsaved; a project with a board keeps it, and
`taskstream_key` without the flag is still ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:21:38 +02:00

2136 lines
107 KiB
Markdown

# 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 `<img>` 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, la casilla «Crear también en TaskStream» del panel o
`taskstream: true` en la API le abren también un tablero en
[TaskStream](https://taskstream.pireon.dev), el gestor de tareas del colectivo.
El panel avisa con el enlace y, al editar, ofrece «Abrir en TaskStream»; la API
lo devuelve en la respuesta:
```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 (en el panel, el de su sesión). Un token de máquina (`pmt_…`)
allí no significa nada, así que esas peticiones usan `TASKSTREAM_TOKEN`; sin
él, no pueden pedir tablero.
- **Después**: un proyecto que se creó sin tablero puede tenerlo luego, con
«Crear en TaskStream» en su ficha o `taskstream: true` en un `PATCH` (si
TaskStream lo rechaza, la edición tampoco se guarda). Uno que ya tiene tablero
lo conserva, y `taskstream_key` sin la casilla se ignora, así que un cliente
puede devolver tal cual lo que leyó. 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).
En la API pública, un proyecto con tablero trae `taskstream` (`key`, `url`,
`web_url` y `showcase_url`). `showcase_url` es público en TaskStream y devuelve
su hoja de ruta, sus versiones, el sprint en curso y los tickets etiquetados
`good-first-issue`, para que la web los enseñe en la página del proyecto.
## 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 `<script>` apuntando al servidor de otro es una dependencia que esta
aplicación no tiene en ningún otro sitio.
El «try it out» apunta al host que estás mirando, no al `APP_URL` de la
configuración, así que la misma página funciona en local, en staging y en
producción. Contra la API pública funciona tal cual, porque no lleva
autenticación.
Tres tests la cubren: que la página abre sin sesión, que no depende de ningún
CDN y que **todo endpoint documentado existe como ruta** — una documentación que
describe un endpoint que nadie implementó es peor que ninguna, porque se cree.
### Que la especificación no mienta
Un documento que describe una API es útil mientras es verdad, y **nada avisa
cuando deja de serlo**. Renombrar un campo en un `PublicApi/*Resource` es una
línea que pasa todos los demás tests, se despliega, y rompe una página en otra
máquina y otro repositorio — escrita contra un documento que sigue describiendo
el nombre viejo. Con el framework del frontend sin decidir, quien lo escriba va
a leer ese documento y no este código.
Dos cosas lo sujetan:
**Dos tests de contrato**, uno por mitad de la API, comparten la comparación en
sí ([`VerifiesOpenApiContract`](tests/VerifiesOpenApiContract.php)) porque la
pregunta es la misma dos veces: si la forma que el documento promete es la que
la API manda. Lo que cambia es todo lo que rodea la pregunta, no la
comparación.
[`PublicApiContractTest`](tests/Feature/Api/PublicApiContractTest.php) llama a
cada endpoint público con datos sembrados y compara la respuesta con el esquema
del `openapi.yaml`, clave a clave. La regla es asimétrica a propósito: **todo lo
que la API manda tiene que estar documentado** —siempre—, y lo que el documento
promete tiene que llegar en los endpoints de detalle, salvo lo que está marcado
`nullable`, que es como este documento dice «puede no estar».
Al escribirlo aparecieron cuatro desajustes que llevaban ahí un tiempo:
`meta_title` y `meta_description` sin documentar en proyectos y servicios,
`updated_at` sin documentar en servicios, el `logo` de las tecnologías (añadido
después de escribir esa parte del documento) y un `featured_image_alt` que las
páginas mandaban **por duplicado**, al nivel de arriba y dentro de
`featured_image.alt`. Los tres primeros eran el documento con retraso; el cuarto
era la API: el alt vive dentro de la imagen, que es donde lo necesita un `<img>`,
y dos nombres para el mismo texto es cómo un frontend acaba leyendo el que
resulta ser el accidental.
[`AdminApiContractTest`](tests/Feature/Api/AdminApiContractTest.php) hace lo
mismo con `/v1/*`, autenticado como `super_admin` para que un permiso de menos
nunca se confunda con una forma equivocada, y añade una comprobación que la
pública no necesita: el **sobre de paginación** (`links`, `meta`) de cada
listado contra lo documentado, y que `/v1/roles` —el único listado que Laravel
no pagina— no lleve ninguno de los dos. Escribirlo encontró cuatro desajustes
más: `featured_image_alt` sin documentar en absoluto en la respuesta de página
(la petición de escritura sí lo tenía, con otra forma), `permissions` de
usuario documentado solo en prosa en vez de con `nullable: true`, y el sobre de
paginación real de Laravel —que manda `from`, `to`, `path` y un `links` de
primer nivel— documentado con la mitad de esas claves y sin ese `links` en
absoluto.
**Tipos generados** para el otro lado:
```bash
php artisan api:types # escribe public/api-types.d.ts
php artisan api:types --check # falla si el fichero se quedó atrás
```
Salen los esquemas públicos y un mapa de cada ruta a lo que devuelve:
```ts
export interface PublicApiPayloads {
'/public/v1/projects': PublicProject[];
'/public/v1/projects/{slug}': PublicProject;
...
}
```
Va **commiteado** —quien lo necesita debería poder leerlo sin ejecutar nada— y
`--check` es lo que impide que se quede viejo. Las descripciones del documento
viajan como comentarios: la mitad de lo que sabe el `openapi.yaml` está en su
prosa, y un tipo a secas no la conserva.
## Servidor MCP
[`mcp/`](mcp/) es un servidor MCP (Node ≥ 20) que da a Claude —o a cualquier
cliente MCP— las dos APIs como herramientas: `public_projects_index`,
`admin_pages_update`, `admin_roles_catalogue`… Una por operación documentada,
con el nombre de la ruta de Laravel en la que acaba.
**No hay ninguna escrita a mano.** Lee el `openapi.yaml` que sirve la
aplicación a la que apunta y las genera de ahí, así que describe la API con la
que está hablando —local, staging o producción— y no la de este checkout. Por lo
mismo que los tipos: el documento lo sujetan los tests de contrato, y una
segunda descripción de la API escrita a mano no la sujetaría nada. La
introducción del documento (las dos APIs, idiomas, campos traducibles) viaja
como instrucciones del servidor.
### Desplegado (HTTP)
Va en el stack como un contenedor más, `landing-mcp`, que habla con
`landing-app` por la red interna. En Dokploy, añádele un dominio en la pestaña
«Domains» apuntando al puerto 3000; el endpoint es `https://<dominio>/mcp`
(Streamable HTTP, sin estado).
**No tiene credenciales propias.** Cada petición trae las de quien la hace, y
la API aplica los permisos de esa persona, igual que en el panel:
```bash
claude mcp add --transport http landing https://mcp.example.org/mcp \
--header "Authorization: Basic $(printf 'tu@email.com:contraseña' | base64)"
```
- **`Basic`** con el email y la contraseña de Pireon: el servidor hace login la
primera vez, guarda la sesión en memoria (indexada por un hash, nunca en
disco ni en logs), refresca el token antes de que caduque y la olvida tras
12 horas sin uso. Es lo recomendable, porque los tokens de Pireon caducan.
- **`Bearer`** con un token de Pireon: funciona hasta que caduca. Se valida
contra `GET /v1/me` y se revalida cada minuto, como hace la propia app.
- **Sin cabecera**: solo la API pública, que ya es pública.
`LANDING_MCP_REQUIRE_AUTH=true` lo cierra.
Unas credenciales rechazadas se recuerdan un minuto, sin volver a preguntar a
la app. Por eso mismo `POST /v1/auth/token` limita **por cuenta y dirección**
(6/min) y no solo por dirección (con un techo de 30/min por dirección): todos
los logins del MCP salen del mismo contenedor, y un límite por IP haría que la
contraseña mal escrita de uno —o los intentos de un extraño— dejaran fuera a
todos los demás.
El documento se relee cada minuto, así que un endpoint nuevo desplegado en la
app aparece como herramienta sin redesplegar el MCP; si la app está caída, se
sigue usando el último que cargó.
`LANDING_MCP_PREVIEW_TOKEN` está vacío a propósito: con el valor de
`SITE_PREVIEW_TOKEN`, cualquiera que pueda iniciar sesión ve todos los
borradores por las herramientas públicas, sea cual sea su rol. Nunca se manda
a quien llega sin credenciales.
### En local (stdio)
```bash
cd mcp && npm install
```
En este repo ya está registrado en [`.mcp.json`](.mcp.json), que lo lanza por
stdio con la configuración del entorno. Aquí sí las credenciales van en
variables, porque quien lo lanza es quien inicia sesión:
```
LANDING_URL raíz de la aplicación (por defecto http://localhost:8000)
LANDING_USERNAME credenciales de Pireon para la API de administración;
LANDING_PASSWORD el token se pide y se refresca solo
LANDING_TOKEN o un bearer token fijo en vez de credenciales —
lo natural es un token de máquina (pmt_…) con
solo los permisos que el MCP necesita
LANDING_PREVIEW_TOKEN opcional: las herramientas públicas ven borradores
LANDING_OPENAPI opcional: ruta o URL de la especificación
```
`node mcp/server.mjs --http` arranca el modo HTTP en local (en `127.0.0.1:3000`
salvo `LANDING_MCP_HOST`/`LANDING_MCP_PORT`), para probarlo sin Docker.
### En los dos modos
- **Sin credenciales solo aparece la mitad pública.** Ofrecer herramientas que
solo pueden fallar le cuesta al modelo una llamada cada una para descubrirlo.
- **Fuera a propósito**: `POST /v1/auth/token` (es cosa del cliente), la
telemetría (la manda el frontend con su propio secreto) y los dos formularios
del buzón — una llamada a `contact` o `apply` mete una fila en el buzón y
avisa a gente real.
- Las cabeceras no se exponen como parámetros: son secretos, y salen de la
configuración, no del modelo.
- **Subir imágenes** (`admin_media_store`): en local el modelo pasa una ruta de
tu disco (`file_path`) y el servidor la lee y la sube como multipart —en
base64 una foto normal serían cientos de miles de tokens—. **Desplegado no hay
`file_path`**: el servidor leería su propio disco a petición de cualquiera, así
que solo se ofrece `body.url`, que descarga la API con las protecciones de
[Subir por la API](#subir-por-la-api).
`npm test` (en `mcp/`, y en CI) genera las herramientas desde el
`openapi.yaml` de este repo —un cambio en el documento que haga chocar dos
nombres rompe el build aquí, no la sesión de alguien— y prueba el modo HTTP de
punta a punta contra una app simulada.
## Comandos
```bash
php artisan permissions:sync # sincroniza el catálogo de permisos
php artisan user:role email@x.com admin # concede un rol
php artisan user:role email@x.com admin --revoke
php artisan user:role email@x.com admin --only # sustituye todos sus roles
php artisan repos:sync # refresca los repositorios
php artisan repos:sync --sync # en primer plano, con salida
php artisan media:process # deriva lo que falte de las imágenes
php artisan media:process --all # reprocesa todas
php artisan content:schedule # cierra convocatorias vencidas
php artisan content:expiring --dry-run # a quién se avisaría de lo que vence
php artisan content:prune --dry-run # qué se tiraría
php artisan content:prune --media # tira también imágenes huérfanas
php artisan site:check # ¿está la web bien?
php artisan site:check --fail-on=error # para scripts de despliegue
php artisan api:types # tipos TS para el frontend
php artisan api:types --check # falla si se quedaron atrás
php artisan site:maintenance --status # ¿está la web pública en pie?
php artisan site:maintenance --until="+2 hours"
php artisan site:maintenance --up
```
## Tests
```bash
php artisan test # 391 tests, 1121 aserciones
```
En cada push y cada pull request, [`.github/workflows/tests.yml`](.github/workflows/tests.yml)
corre tres cosas: `pint --test`, esta suite contra SQLite (la vuelta rápida) y
`docker compose -f docker-compose.test.yml run --rm test` — el mismo comando de
más abajo, no una reimplementación en YAML — contra Postgres (la que de verdad
importa). Reutiliza el fichero de Docker en vez de describir el entorno dos
veces: un cambio en las extensiones de PHP o en la versión de Postgres se
corrige en un solo sitio.
Cubren el acceso con email y contraseña (credenciales incorrectas
indistinguibles de una dirección desconocida, cuenta desactivada, pendiente de
aprobación o sin rol, límite de intentos y rotación de sesión), el flujo PKCE
completo que sigue disponible, el guard de la API, la separación entre
identidad remota y permisos locales, la negociación de idioma, la fusión de
traducciones parciales, el acceso al panel, el contrato de la API pública
(`alternates`, caché, 304, preview), el buzón (convocatoria cerrada, honeypot,
deduplicación, consentimiento, límite por email, aviso a quien puede leerlo), el
procesado de imágenes (dimensiones, versiones, no ampliar, vectores, srcset y
que un listado no cueste una consulta por imagen), las galerías (orden, pie
frente a alt, borrado en cascada), la vitrina (servicios solo con obra
publicada detrás, logos de quien dio permiso, citas marcadas como traducidas,
títulos de charla sin traducir), las comprobaciones de salud (cada fallo que
detectan, los permisos que ocultan resultados y los códigos de salida), las
redirecciones (que se crean solas, que no encadenan ni dan vueltas y que la web
las recibe de una vez), los 404 que reporta el frontend (que el endpoint no se
abre sin secreto, que cuenta en vez de acumular filas, que normaliza igual que
una redirección, que tira los escaneos y que crear la regla cierra el aviso), la
revisión (que se puede pedir sin permiso de publicar, a quién avisa, que
devolver exige motivo, que publicar la cierra y programar no) y sus acciones en
lote, las ediciones simultáneas (que el segundo guardado se rechaza, que
guardarse a uno mismo no cuenta como conflicto y que la presencia se olvida
sola), las cifras de lectura (que suman en una fila por día, que cada idioma
cuenta aparte y que en la tabla no cabe nada de un visitante), el contrato de
la API contra su especificación campo a campo y los tipos generados de ella,
las traducciones que se quedaron atrás (que el slug no cuenta, que una vacía
no cuenta y que ponerse al día lo limpia), las sesiones activas (que no se
puede cerrar la propia) y el aviso de títulos parecidos, el historial (quién
publicó, el diff y restaurar), la
limpieza (qué se conserva siempre y qué nunca se borra sin pedirlo), las
ventanas de publicación (que se respetan sin nada corriendo, que preview las
salta y que el cierre de convocatorias es idempotente), la sincronización con
Gitea y el modo mantenimiento.
Ningún test toca la red: `tests/FakesPireon.php` simula el servidor de
identidad y las llamadas a GitHub se falsean con `Http::fake()`.
Otro abre **todas las pantallas del panel** —cada listado de recurso y cada
página propia— porque una pantalla que nadie ha visitado es una pantalla que
nadie ha probado: así es como `/admin/settings` llevaba meses roto sin que la
suite se enterara.
Un test comprueba que los tres idiomas llevan **las mismas claves** y que
ninguna está vacía, además de que toda clave que usa el código exista. Una
traducción que falta no rompe nada: se pinta la clave cruda
(`panel.widgets.stats.projects`) en una pantalla que nadie está mirando en ese
idioma, así que solo la encuentra quien la usa.
Un detalle a tener presente al escribir tests nuevos: `Symfony\Request::create()`
inyecta `Accept-Language: en-us` por defecto, y la API respeta la negociación de
idioma. Si un test espera castellano tiene que pedirlo explícitamente
(`Accept-Language: es` o `?lang=es`), como hace `PublicApiTest`.
## Accesibilidad del propio panel
El texto alternativo de la web pública tiene su sección propia; esto es sobre
la herramienta, no sobre lo que publica. Filament ya se ocupa de lo suyo —
contraste, roles ARIA, navegación por teclado entre pestañas— y no se ha
tocado nada de eso. Lo que se ha revisado son las **pocas vistas Blade
escritas a mano** en `resources/views/filament/`, donde nada garantiza esas
cosas por defecto.
Dos reglas, aplicadas donde tocaba:
**Un icono junto a un texto que ya dice lo mismo lleva `aria-hidden="true"`.**
El aviso de mantenimiento, el de salud del sitio: el icono de aviso al lado de
«La web pública está en mantenimiento» no añade nada que un lector de
pantalla necesite oír dos veces.
**Un icono que es la única señal de algo lleva su propio `aria-label`.** El de
cada comprobación de salud es el único caso: la gravedad —fallo o aviso— no
está escrita en ningún sitio, solo se ve en la forma y el color del icono.
Ocultarlo habría sido perder esa información, no limpiarla.
De paso, los enlaces de vista previa que abren en pestaña nueva llevan
`rel="noopener"` (la página de destino, en otro origen, no debe quedarse con
una referencia a esta) y el campo de solo lectura que copia la URL tiene su
propio `aria-label`, porque el texto que lo identifica visualmente es un
párrafo aparte sin relación programática con él.
## Estructura
```
docker-compose.yml stack de producción (landing-app, worker, scheduler, landing-mcp, postgres)
docker-compose.test.yml la suite contra Postgres, desechable
Dockerfile imagen única para los tres contenedores PHP
docker/ Caddyfile, php.ini y entrypoint
app/
Auth/PireonTokenGuard.php validación de bearer tokens
Services/Pireon/ cliente OAuth (PKCE, grants, introspección)
Services/Git/ drivers de repositorios (Gitea + GitHub)
Services/Media/ImageProcessor.php dimensiones, versiones y placeholder
Actions/Auth/SyncPireonUser.php identidad remota → usuario local
Jobs/SyncRepository.php refresco de un repositorio
Support/Permissions.php catálogo de permisos (fuente única)
Support/TranslatablePayload.php contrato JSON ↔ traducciones
Support/SiteUrls.php rutas públicas y alternates hreflang
Support/ImagePayload.php forma de una imagen en la API
Support/Maintenance.php mantenimiento de la web pública
Support/Health/ comprobaciones de salud del sitio
Policies/ autorización compartida panel + API
Filament/ panel: recursos, login, switcher de idioma
Http/Controllers/Api/V1/ API de administración
Http/Controllers/Api/PublicApi/V1/ API pública para el frontend
Notifications/ApplicationReceived aviso al panel de que alguien escribió
Http/Middleware/CachePublicResponse ETag, s-maxage, stale-while-revalidate
Http/Middleware/AllowPreview secreto de preview
Http/Middleware/VerifyIngestToken secreto de los reportes del frontend
Http/Middleware/EnsureSiteIsAvailable 503 mientras hay mantenimiento
config/pireon.php servidor OAuth
config/locales.php idiomas soportados
config/site.php frontend público, preview, caché, rate limit, telemetría
config/media.php disco, anchos y formatos de imagen
config/git.php proveedores de git y etiquetas de issues
public/openapi.yaml especificación de ambas APIs
mcp/ servidor MCP (stdio y HTTP) generado desde la anterior
```
## Despliegue con Docker
Cuatro contenedores y una base de datos:
```bash
cp .env.production.example .env.production
# Ahora edita .env.production: al menos DB_PASSWORD tiene que tener un valor
# real, porque docker-compose.yml no arranca sin él — ni siquiera para el
# comando siguiente, que solo interpola el fichero entero antes de ejecutar
# nada. APP_KEY puede quedarse vacío por ahora, se rellena con este paso:
docker compose --env-file .env.production run --rm --no-deps landing-app php artisan key:generate --show # pégalo en APP_KEY
docker compose --env-file .env.production up -d --build
# el primer administrador, igual que en local
docker compose exec landing-app php artisan user:role tu@email.com super_admin
```
Todas las variables de `docker-compose.yml` llegan por interpolación `${VAR}`,
no por `env_file:` — así que esto funciona igual con `--env-file`, con
variables ya exportadas en el shell, o con el panel de "Environment
Variables" de una plataforma como Dokploy o Coolify, que las inyecta como
variables de entorno reales del proceso que lanza `docker compose`. Ninguna
de las tres formas necesita que exista un fichero `.env.production` en el
servidor.
| Contenedor | Qué hace |
|---|---|
| `landing-app` | FrankenPHP sirviendo el panel y la API |
| `worker` | procesa imágenes y manda los avisos del buzón |
| `scheduler` | cierra convocatorias, barre imágenes pendientes, sincroniza repos |
| `postgres` | la base de datos |
**No hay Redis.** Postgres lleva la caché, las sesiones y la cola: para un sitio
de este tamaño sobra, y es una cosa menos que ejecutar, respaldar y olvidarse de
asegurar. Cambiarlo más adelante son tres variables de entorno.
**El worker no es opcional.** Sin él las imágenes se sirven sin dimensiones ni
`srcset` y la campana del panel no suena nunca. La web sigue funcionando, que es
justo por lo que la ausencia pasa desapercibida.
**Solo `app` migra.** `RUN_MIGRATIONS=true` está puesto ahí y en ningún otro
sitio, para que un despliegue las ejecute una vez y no una por contenedor. Con
`--isolated`, así que dos arranques simultáneos no se pisan.
**El volumen `storage` es lo único irrecuperable** del stack: contiene las
imágenes subidas. Todo lo demás se reconstruye desde el repositorio y la base de
datos. Respáldalo junto con `pgdata`.
**El primer arranque contra una base vacía tiene una trampa** que ya está
resuelta y conviene no reintroducir: `migrate --isolated` toma un candado en la
caché, y con `CACHE_STORE=database` esa tabla la crean las propias migraciones.
El entrypoint fuerza `CACHE_STORE=file` solo para ese comando — el valor de
`--isolated` es un código de salida, no un almacén.
**Postgres no se publica.** Nada fuera del stack tiene por qué llegar a él; para
entrar, `docker compose exec postgres psql -U pireon`.
**La imagen usa PHP 8.4**, no el 8.3 que sugiere `composer.json`: el lock
resolvió Symfony 8, que exige `>=8.4.1`. Si algún día tocas ese constraint,
acuérdate de la imagen — construir sobre 8.3 falla en `composer install`, que
es mucho mejor sitio para enterarse que un despliegue.
La imagen **no lleva Node**: la única directiva `@vite` que queda está en la
página de bienvenida de Laravel, a la que esta aplicación redirige. Los assets
de Filament se publican en tiempo de construcción, así que el contenedor en
marcha nunca escribe en `public/`.
### Lo que se rompió al pasar a Postgres
Este proyecto vivió sobre SQLite hasta que existió este stack, y ejecutarlo de
verdad contra Postgres sacó cuatro cosas que ninguna revisión de código habría
visto:
**Las columnas JSON tuvieron que pasar a `jsonb`** (48 de ellas). Postgres no
sabe comparar un `json` para igualdad, así que cualquier `select distinct` que
tocara una fallaba — y los selectores de relación de Filament emiten justo eso.
En MySQL sigue siendo `json` y en SQLite sigue siendo `text`, así que no cambia
nada más.
**`notifications.data` también.** La campana de Filament la consulta por ruta
JSON (`data->>'format'`), y el stub estándar de Laravel la declara como `text`:
en Postgres ese operador no existe.
**`jsonb` no conserva el orden de las claves.** Es inofensivo aquí —toda columna
JSON es un mapa cuyo orden no significa nada, y las listas sí mantienen su
orden—, pero cuatro tests comparaban con `assertSame`, que exige también el
orden.
**Y un bug real:** el historial se ordenaba solo por `created_at`, y dos
revisiones del mismo registro caen rutinariamente en el mismo segundo —crear y
editar seguido—. Cuál era «la última» quedaba a criterio del motor: SQLite
devolvía una y Postgres otra. Ahora desempata por `id`.
### Antes de confiar en él: probar contra Postgres
Este proyecto guarda traducciones, slugs y variantes de imagen en **columnas
JSON** y las consulta por ruta (`where("slug->es", …)`) en unos cuarenta sitios.
Hasta ahora eso solo se había ejecutado sobre SQLite, y los tres motores
coinciden en la sintaxis pero no siempre en el comportamiento.
```bash
docker compose -f docker-compose.test.yml run --rm test
```
Levanta un Postgres en memoria, corre la suite entera contra él y lo tira. Es la
comprobación que convierte «debería funcionar» en «funciona», y conviene hacerla
antes del primer despliegue. **391 tests en verde contra Postgres 17.**
Usa una etapa propia de la imagen: la de producción se construye con `--no-dev`
y ahí no está ni el runner de tests.
## Notas de despliegue
- `PIREON_REDIRECT_URI` debe coincidir **exactamente** con la registrada en el
servidor de identidad, incluido el esquema.
- En producción las URLs se fuerzan a HTTPS (`AppServiceProvider`).
- Ejecuta `php artisan permissions:sync` en cada despliegue que toque el
catálogo de permisos.
- El seeder de contenido de ejemplo no se ejecuta en producción.
- La sincronización de repositorios necesita el scheduler
(`php artisan schedule:work` o una entrada de cron) y un worker de colas si
usas el botón de sincronización masiva.
- El procesado de imágenes va por cola. Sin worker las imágenes se sirven igual
pero sin dimensiones ni `srcset`, y el scheduler las recupera cada diez
minutos.
- `php artisan storage:link` una vez, si `MEDIA_DISK=public`.
- `GITEA_URL` debe ser la raíz de la instancia, **sin** `/api/v1`.
- El modo mantenimiento vive en la tabla `settings` y se cachea: no hace falta
desplegar para activarlo, pero sí un almacén de caché compartido si tienes
varias instancias.
- Añade la IP del servidor del frontend a `SITE_TRUSTED_IPS`, o el rate limit de
la API pública se aplicará a toda la web como si fuera un solo cliente.
- `SITE_URL` debe apuntar al dominio público real: de ahí salen los
`alternates` absolutos que el frontend usa para los `hreflang`. También es
con lo que se compara la procedencia de un 404 para saber si el enlace roto
es nuestro.
- `SITE_INGEST_TOKEN` vacío deja el reporte de 404 apagado, que es un valor por
defecto seguro y también una funcionalidad que no está funcionando. Si la
lista de **URLs no encontradas** lleva semanas vacía, empieza por ahí.
- Las notificaciones del buzón **no van por cola**, a propósito: un despliegue
sin worker dejaría los avisos sin llegar, que es justo lo que un buzón no
puede permitirse. Si activas `SITE_SUBMISSION_MAIL=true` el mailer pasa a
estar en el camino de una petición pública (un fallo se registra y no rompe el
envío, pero lo ralentiza).
- Con `APP_ENV=production` (lo que trae `.env.production.example`), el panel
lleva una franja roja arriba del todo en cada pantalla, siempre — no hay
forma de apagarla salvo cambiar el entorno. Existe por el error que de verdad
pasa: editar producción pensando que es un clon local, nunca al revés. Un
`APP_ENV` que no sea ni `local` ni `production` (`staging`, por ejemplo) sale
en ámbar con su propio nombre; `local` no enseña nada.