Josep Piguillem daf6098203 Load a page's image record in the admin API
PageResource only fills `featured_image_media` from the `featuredMedia`
relation when it is loaded, and PageController never loaded it, so every
admin response carried the image's URL with no dimensions, srcset or
placeholder even when the processed record existed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:16:31 +02:00
2026-08-22 22:48:56 +02:00
2026-08-22 22:48:56 +02:00
2026-08-22 23:27:07 +02:00

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 — 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:

node docs/build-pdf.mjs

Puesta en marcha

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 (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:

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:

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, /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), 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 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):

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.

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 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 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.
  • 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.

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), 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:

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); 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, 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:

"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. 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.

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.

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.

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, 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.

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). 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:

  • 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 y registrarla en config/git.php, sin tocar nada más.

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 y 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:

{
  "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
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

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.

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.

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
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
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.

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.

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 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. Copia todo menos el slug y las fechas, porque dos registros no pueden responder en la misma URL.

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.

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:

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:

{
  "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 (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

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.

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) 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 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 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:

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:

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/ 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:

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)

cd mcp && npm install

En este repo ya está registrado en .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
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.

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

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: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

php artisan test    # 391 tests, 1121 aserciones

En cada push y cada pull request, .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:

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.

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.
Description
No description provided
Readme 5.5 MiB
Languages
PHP 93.3%
Blade 6.1%
Shell 0.3%
Dockerfile 0.3%