Compare commits
37 Commits
main
...
9f970808ca
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f970808ca | |||
| 6fc600e6a1 | |||
| 76d022053b | |||
| 697d333ad4 | |||
| 8d0f81f9c5 | |||
| 773d3901c6 | |||
| 366568b793 | |||
| 70521d4bc9 | |||
| 1fe1e67773 | |||
| 9bdbf834f6 | |||
| f93775e549 | |||
| 0119004f2c | |||
| 6b3517743a | |||
| 68c5bd4274 | |||
| 5b46436641 | |||
| 4c4a2b3299 | |||
| 8d0f2e3639 | |||
| 4c65604f04 | |||
| b02464a7ed | |||
| 96747abdc3 | |||
| 3daa40570b | |||
| 82ef2b38dd | |||
| 6b67c7afd4 | |||
| 7e672a5e62 | |||
| ac546eba2b | |||
| a49816eb46 | |||
| daf6098203 | |||
| 748b485c9b | |||
| 5ca084bd4a | |||
| 5558db6d56 | |||
| 1e925a2b07 | |||
| 080c4238e8 | |||
| ffde884f5b | |||
| 2a58833190 | |||
| c30863ab9b | |||
| 2cca79cf04 | |||
| 033cec6573 |
@@ -20,3 +20,6 @@ database/*.sqlite-journal
|
|||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
docs/*.pdf
|
docs/*.pdf
|
||||||
public/storage
|
public/storage
|
||||||
|
|
||||||
|
# Runs on a developer machine, next to Claude Code, never in the image.
|
||||||
|
mcp
|
||||||
|
|||||||
@@ -2,10 +2,17 @@
|
|||||||
# Production environment for the Docker stack.
|
# Production environment for the Docker stack.
|
||||||
#
|
#
|
||||||
# cp .env.production.example .env.production
|
# cp .env.production.example .env.production
|
||||||
# docker compose run --rm --no-deps app php artisan key:generate --show
|
# # fill in at least DB_PASSWORD below — docker-compose.yml refuses to
|
||||||
|
# # interpolate at all without it, even for the next command
|
||||||
|
# docker compose --env-file .env.production run --rm --no-deps landing-app php artisan key:generate --show
|
||||||
#
|
#
|
||||||
# Everything the containers need is here; docker-compose.yml sets the database,
|
# Everything the containers need is here; docker-compose.yml sets the database,
|
||||||
# cache, session and queue drivers itself so they cannot drift.
|
# cache, session and queue drivers itself so they cannot drift.
|
||||||
|
#
|
||||||
|
# This file only matters for a manual deploy. A platform that injects its own
|
||||||
|
# "Environment Variables" (Dokploy, Coolify, ...) exports them as real process
|
||||||
|
# environment variables instead, which docker-compose.yml's `${VAR}`
|
||||||
|
# interpolation reads directly — no file needed on the server at all.
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
APP_NAME="Pireon Admin"
|
APP_NAME="Pireon Admin"
|
||||||
@@ -116,3 +123,18 @@ MAIL_USERNAME=
|
|||||||
MAIL_PASSWORD=
|
MAIL_PASSWORD=
|
||||||
MAIL_FROM_ADDRESS="admin@example.org"
|
MAIL_FROM_ADDRESS="admin@example.org"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# MCP server (landing-mcp)
|
||||||
|
#
|
||||||
|
# Holds no credentials: every caller signs in with their own. Its domain is
|
||||||
|
# added in Dokploy's "Domains" tab, pointing at port 3000.
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
# "true" refuses callers without credentials instead of offering them the
|
||||||
|
# public API — which is already public, so the default is to allow it.
|
||||||
|
LANDING_MCP_REQUIRE_AUTH=false
|
||||||
|
|
||||||
|
# Empty on purpose. Set to SITE_PREVIEW_TOKEN's value and anyone who can sign
|
||||||
|
# in sees every draft through the public tools, whatever their role.
|
||||||
|
LANDING_MCP_PREVIEW_TOKEN=
|
||||||
|
|||||||
21
.github/workflows/tests.yml
vendored
21
.github/workflows/tests.yml
vendored
@@ -79,3 +79,24 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- run: docker compose -f docker-compose.test.yml run --rm test
|
- run: docker compose -f docker-compose.test.yml run --rm test
|
||||||
|
|
||||||
|
# The MCP server derives its tools from openapi.yaml, so a change to the
|
||||||
|
# document can break it without touching a line under mcp/ — two operations
|
||||||
|
# that map to the same tool name, say. Cheap enough to run on every push.
|
||||||
|
mcp:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: mcp
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: mcp/package-lock.json
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
- run: npm test
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -27,3 +27,4 @@ _ide_helper.php
|
|||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
/mcp/node_modules
|
||||||
|
|||||||
15
.mcp.json
Normal file
15
.mcp.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"landing": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["mcp/server.mjs"],
|
||||||
|
"env": {
|
||||||
|
"LANDING_URL": "${LANDING_URL:-http://localhost:8000}",
|
||||||
|
"LANDING_USERNAME": "${LANDING_USERNAME:-}",
|
||||||
|
"LANDING_PASSWORD": "${LANDING_PASSWORD:-}",
|
||||||
|
"LANDING_TOKEN": "${LANDING_TOKEN:-}",
|
||||||
|
"LANDING_PREVIEW_TOKEN": "${LANDING_PREVIEW_TOKEN:-}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
Dockerfile
30
Dockerfile
@@ -20,13 +20,13 @@ WORKDIR /app
|
|||||||
# sideways staying sideways. pcntl lets the queue worker shut down cleanly
|
# sideways staying sideways. pcntl lets the queue worker shut down cleanly
|
||||||
# instead of being killed mid-job.
|
# instead of being killed mid-job.
|
||||||
RUN install-php-extensions \
|
RUN install-php-extensions \
|
||||||
pdo_pgsql \
|
pdo_pgsql \
|
||||||
gd \
|
gd \
|
||||||
exif \
|
exif \
|
||||||
intl \
|
intl \
|
||||||
zip \
|
zip \
|
||||||
pcntl \
|
pcntl \
|
||||||
opcache \
|
opcache \
|
||||||
&& apt-get update \
|
&& apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends postgresql-client \
|
&& apt-get install -y --no-install-recommends postgresql-client \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
@@ -49,11 +49,11 @@ COPY composer.json composer.lock ./
|
|||||||
# application cannot boot before its code is here. They are run explicitly in
|
# application cannot boot before its code is here. They are run explicitly in
|
||||||
# the runtime stage instead, where everything exists.
|
# the runtime stage instead, where everything exists.
|
||||||
RUN composer install \
|
RUN composer install \
|
||||||
--no-dev \
|
--no-dev \
|
||||||
--no-scripts \
|
--no-scripts \
|
||||||
--no-autoloader \
|
--no-autoloader \
|
||||||
--prefer-dist \
|
--prefer-dist \
|
||||||
--no-interaction
|
--no-interaction
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ RUN touch .env \
|
|||||||
FROM base AS runtime
|
FROM base AS runtime
|
||||||
|
|
||||||
LABEL org.opencontainers.image.title="Pireon Admin" \
|
LABEL org.opencontainers.image.title="Pireon Admin" \
|
||||||
org.opencontainers.image.description="Admin panel and REST API for the collective"
|
org.opencontainers.image.description="Admin panel and REST API for the collective"
|
||||||
|
|
||||||
COPY docker/Caddyfile /etc/caddy/Caddyfile
|
COPY docker/Caddyfile /etc/caddy/Caddyfile
|
||||||
|
|
||||||
@@ -113,9 +113,9 @@ RUN php artisan package:discover --ansi \
|
|||||||
|
|
||||||
RUN chown -R www-data:www-data storage bootstrap/cache
|
RUN chown -R www-data:www-data storage bootstrap/cache
|
||||||
|
|
||||||
ENV SERVER_NAME=:80
|
ENV SERVER_NAME=:8080
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 8080
|
||||||
|
|
||||||
ENTRYPOINT ["entrypoint"]
|
ENTRYPOINT ["entrypoint"]
|
||||||
|
|
||||||
|
|||||||
450
README.md
450
README.md
@@ -390,14 +390,52 @@ 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
|
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**.
|
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
|
No se sube desde ahí: los archivos llegan al subirlos en una ficha (o por la
|
||||||
puede borrar uno que algo esté usando** — dejaría una imagen rota en la web, que
|
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
|
es justo de lo que se queja la página de salud. El botón solo aparece en los
|
||||||
huérfanos.
|
huérfanos.
|
||||||
|
|
||||||
«Reprocesar todo» es la respuesta a «he cambiado los anchos, ¿y ahora?»: la
|
«Reprocesar todo» es la respuesta a «he cambiado los anchos, ¿y ahora?»: la
|
||||||
tabla es derivada, así que reconstruirla siempre es seguro.
|
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
|
### Galerías
|
||||||
|
|
||||||
Un proyecto tiene además una galería —capturas, el antes y el después— que se
|
Un proyecto tiene además una galería —capturas, el antes y el después— que se
|
||||||
@@ -625,6 +663,22 @@ 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
|
renderizara en cliente necesitaría otra cosa —un token de preview por sesión y
|
||||||
con caducidad—, no este secreto compartido.
|
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
|
## La vitrina: servicios, clientes, testimonios y charlas
|
||||||
|
|
||||||
Cuatro tipos de contenido con los que se compone una landing o un portfolio.
|
Cuatro tipos de contenido con los que se compone una landing o un portfolio.
|
||||||
@@ -903,6 +957,22 @@ 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
|
El cálculo se cachea contra el `updated_at` del registro, así que se rehace en
|
||||||
cuanto algo cambia y ni un minuto antes.
|
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
|
## Revisión
|
||||||
|
|
||||||
`pages.publish` lleva separado desde el principio: **poner algo en la web es un
|
`pages.publish` lleva separado desde el principio: **poner algo en la web es un
|
||||||
@@ -1118,9 +1188,58 @@ 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
|
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.
|
puede mandar por chat sin dar acceso a todos los borradores del sitio.
|
||||||
|
|
||||||
**Duplicar** — en páginas, proyectos, servicios y convocatorias. Copia todo
|
**Duplicar** — en páginas, proyectos, servicios y convocatorias, en la tabla y
|
||||||
menos el slug y las fechas, porque dos registros no pueden responder en la misma
|
en el formulario, y por API (`POST /api/v1/{tipo}/{id}/duplicate`). La copia es
|
||||||
URL.
|
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.
|
**Búsqueda global** — los doce recursos, desde la barra de arriba.
|
||||||
|
|
||||||
@@ -1225,13 +1344,206 @@ POST /api/v1/pages/{page}/restore
|
|||||||
GET /api/v1/users PATCH para roles, is_active, locale
|
GET /api/v1/users PATCH para roles, is_active, locale
|
||||||
GET /api/v1/roles solo lectura
|
GET /api/v1/roles solo lectura
|
||||||
GET /api/v1/roles/catalogue catálogo de permisos agrupado
|
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
|
`GET /v1/me` devuelve el array `permissions`: úsalo para construir la UI del
|
||||||
cliente en vez de comprobar nombres de rol.
|
cliente en vez de comprobar nombres de rol.
|
||||||
|
|
||||||
Quien no tenga `pages.publish` solo ve páginas publicadas en el listado.
|
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
|
||||||
|
|
||||||
|
```
|
||||||
|
/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` (o `*`). 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
|
## Documentación navegable
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -1332,6 +1644,107 @@ Va **commiteado** —quien lo necesita debería poder leerlo sin ejecutar nada
|
|||||||
viajan como comentarios: la mitad de lo que sabe el `openapi.yaml` está en su
|
viajan como comentarios: la mitad de lo que sabe el `openapi.yaml` está en su
|
||||||
prosa, y un tipo a secas no la conserva.
|
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
|
## Comandos
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -1347,6 +1760,7 @@ php artisan media:process # deriva lo que falte de las imáge
|
|||||||
php artisan media:process --all # reprocesa todas
|
php artisan media:process --all # reprocesa todas
|
||||||
|
|
||||||
php artisan content:schedule # cierra convocatorias vencidas
|
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 --dry-run # qué se tiraría
|
||||||
php artisan content:prune --media # tira también imágenes huérfanas
|
php artisan content:prune --media # tira también imágenes huérfanas
|
||||||
|
|
||||||
@@ -1364,7 +1778,7 @@ php artisan site:maintenance --up
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
php artisan test # 394 tests, 1128 aserciones
|
php artisan test # 391 tests, 1121 aserciones
|
||||||
```
|
```
|
||||||
|
|
||||||
En cada push y cada pull request, [`.github/workflows/tests.yml`](.github/workflows/tests.yml)
|
En cada push y cada pull request, [`.github/workflows/tests.yml`](.github/workflows/tests.yml)
|
||||||
@@ -1458,7 +1872,7 @@ párrafo aparte sin relación programática con él.
|
|||||||
## Estructura
|
## Estructura
|
||||||
|
|
||||||
```
|
```
|
||||||
docker-compose.yml stack de producción (app, worker, scheduler, postgres)
|
docker-compose.yml stack de producción (landing-app, worker, scheduler, landing-mcp, postgres)
|
||||||
docker-compose.test.yml la suite contra Postgres, desechable
|
docker-compose.test.yml la suite contra Postgres, desechable
|
||||||
Dockerfile imagen única para los tres contenedores PHP
|
Dockerfile imagen única para los tres contenedores PHP
|
||||||
docker/ Caddyfile, php.ini y entrypoint
|
docker/ Caddyfile, php.ini y entrypoint
|
||||||
@@ -1491,6 +1905,7 @@ config/site.php frontend público, preview, caché, rate
|
|||||||
config/media.php disco, anchos y formatos de imagen
|
config/media.php disco, anchos y formatos de imagen
|
||||||
config/git.php proveedores de git y etiquetas de issues
|
config/git.php proveedores de git y etiquetas de issues
|
||||||
public/openapi.yaml especificación de ambas APIs
|
public/openapi.yaml especificación de ambas APIs
|
||||||
|
mcp/ servidor MCP (stdio y HTTP) generado desde la anterior
|
||||||
```
|
```
|
||||||
|
|
||||||
## Despliegue con Docker
|
## Despliegue con Docker
|
||||||
@@ -1499,16 +1914,29 @@ Cuatro contenedores y una base de datos:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.production.example .env.production
|
cp .env.production.example .env.production
|
||||||
docker compose run --rm --no-deps app php artisan key:generate --show # pégalo en APP_KEY
|
# 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
|
docker compose --env-file .env.production up -d --build
|
||||||
|
|
||||||
# el primer administrador, igual que en local
|
# el primer administrador, igual que en local
|
||||||
docker compose exec app php artisan user:role tu@email.com super_admin
|
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 |
|
| Contenedor | Qué hace |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `app` | FrankenPHP sirviendo el panel y la API |
|
| `landing-app` | FrankenPHP sirviendo el panel y la API |
|
||||||
| `worker` | procesa imágenes y manda los avisos del buzón |
|
| `worker` | procesa imágenes y manda los avisos del buzón |
|
||||||
| `scheduler` | cierra convocatorias, barre imágenes pendientes, sincroniza repos |
|
| `scheduler` | cierra convocatorias, barre imágenes pendientes, sincroniza repos |
|
||||||
| `postgres` | la base de datos |
|
| `postgres` | la base de datos |
|
||||||
@@ -1587,7 +2015,7 @@ 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
|
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
|
comprobación que convierte «debería funcionar» en «funciona», y conviene hacerla
|
||||||
antes del primer despliegue. **394 tests en verde contra Postgres 17.**
|
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`
|
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.
|
y ahí no está ni el runner de tests.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Auth;
|
namespace App\Auth;
|
||||||
|
|
||||||
use App\Actions\Auth\SyncPireonUser;
|
use App\Actions\Auth\SyncPireonUser;
|
||||||
|
use App\Models\ApiToken;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Pireon\PireonClient;
|
use App\Services\Pireon\PireonClient;
|
||||||
use App\Services\Pireon\PireonException;
|
use App\Services\Pireon\PireonException;
|
||||||
@@ -35,7 +36,16 @@ class PireonTokenGuard
|
|||||||
{
|
{
|
||||||
$token = $request->bearerToken();
|
$token = $request->bearerToken();
|
||||||
|
|
||||||
if (blank($token) || ! $this->client->isConfigured()) {
|
if (blank($token)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ours, not the identity server's: checked here and never sent there.
|
||||||
|
if (str_starts_with($token, ApiToken::PREFIX)) {
|
||||||
|
return $this->machine($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->client->isConfigured()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +62,21 @@ class PireonTokenGuard
|
|||||||
return $user->is_active ? $user : null;
|
return $user->is_active ? $user : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function machine(string $plain): ?User
|
||||||
|
{
|
||||||
|
$token = ApiToken::findUsable($plain);
|
||||||
|
$user = $token?->user;
|
||||||
|
|
||||||
|
// A person switched off here takes their tokens with them.
|
||||||
|
if ($user === null || ! $user->is_active) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token->touchLastUsed();
|
||||||
|
|
||||||
|
return $user->withAccessToken($token);
|
||||||
|
}
|
||||||
|
|
||||||
protected function resolveIdentity(string $token): ?PireonIdentity
|
protected function resolveIdentity(string $token): ?PireonIdentity
|
||||||
{
|
{
|
||||||
$key = 'pireon:introspect:'.hash('sha256', $token);
|
$key = 'pireon:introspect:'.hash('sha256', $token);
|
||||||
|
|||||||
60
app/Console/Commands/NotifyExpiringCommand.php
Normal file
60
app/Console/Commands/NotifyExpiringCommand.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\ExpiringSoon;
|
||||||
|
use App\Support\Expiring;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells editors, once a day, about what closes or comes off the site on one
|
||||||
|
* of the reminder days ahead (a week and a day, by default) — so each thing
|
||||||
|
* is mentioned a couple of times rather than every morning for a fortnight.
|
||||||
|
*/
|
||||||
|
class NotifyExpiringCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'content:expiring
|
||||||
|
{--dry-run : List who would be told what, without telling them}';
|
||||||
|
|
||||||
|
protected $description = 'Remind editors of calls closing and content coming off the site soon';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$days = array_map('intval', (array) config('site.expiry_reminders', [7, 1]));
|
||||||
|
|
||||||
|
$due = Expiring::within(max($days))
|
||||||
|
->filter(fn (array $item) => in_array((int) now()->startOfDay()->diffInDays($item['at']->copy()->startOfDay()), $days, true))
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($due->isEmpty()) {
|
||||||
|
$this->components->info(__('console.expiring.none'));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$told = 0;
|
||||||
|
|
||||||
|
User::query()->where('is_active', true)->get()->each(function (User $user) use ($due, &$told): void {
|
||||||
|
$mine = Expiring::for($due, $user);
|
||||||
|
|
||||||
|
if ($mine->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->components->twoColumnDetail($user->name, (string) $mine->count());
|
||||||
|
|
||||||
|
if (! $this->option('dry-run')) {
|
||||||
|
$user->notify(new ExpiringSoon($mine));
|
||||||
|
}
|
||||||
|
|
||||||
|
$told++;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->components->info(trans_choice('console.expiring.summary', $told, ['count' => $told]));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/Filament/Charts/ViewsByLanguageChart.php
Normal file
52
app/Filament/Charts/ViewsByLanguageChart.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Charts;
|
||||||
|
|
||||||
|
use App\Support\ViewStats;
|
||||||
|
use Filament\Widgets\ChartWidget;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads per day, one line per language. Lives outside `Filament/Widgets` so
|
||||||
|
* the dashboard does not discover it: it belongs to the statistics page,
|
||||||
|
* which says which period to draw.
|
||||||
|
*/
|
||||||
|
class ViewsByLanguageChart extends ChartWidget
|
||||||
|
{
|
||||||
|
public int $days = 30;
|
||||||
|
|
||||||
|
protected int|string|array $columnSpan = 'full';
|
||||||
|
|
||||||
|
protected ?string $maxHeight = '280px';
|
||||||
|
|
||||||
|
public function getHeading(): string
|
||||||
|
{
|
||||||
|
return __('panel.statistics.daily');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getType(): string
|
||||||
|
{
|
||||||
|
return 'line';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getData(): array
|
||||||
|
{
|
||||||
|
$daily = (new ViewStats($this->days))->daily();
|
||||||
|
$colors = ['#f59e0b', '#3b82f6', '#10b981', '#ef4444', '#8b5cf6'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'labels' => $daily['labels'],
|
||||||
|
'datasets' => collect($daily['series'])
|
||||||
|
->values()
|
||||||
|
->map(fn (array $data, int $i) => [
|
||||||
|
'label' => config('locales.supported.'.array_keys($daily['series'])[$i].'.native'),
|
||||||
|
'data' => $data,
|
||||||
|
'borderColor' => $colors[$i % count($colors)],
|
||||||
|
'backgroundColor' => $colors[$i % count($colors)],
|
||||||
|
'tension' => 0.3,
|
||||||
|
])
|
||||||
|
->all(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
123
app/Filament/Pages/EditorialCalendar.php
Normal file
123
app/Filament/Pages/EditorialCalendar.php
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Support\EditorialCalendar as Calendar;
|
||||||
|
use BackedEnum;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Filament\Pages\Page as FilamentPage;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A month at a glance: what goes live, what comes off, which calls open and
|
||||||
|
* close. The dates were always there, one form at a time; this is them side
|
||||||
|
* by side, which is where a clash or an empty fortnight shows.
|
||||||
|
*/
|
||||||
|
class EditorialCalendar extends FilamentPage
|
||||||
|
{
|
||||||
|
protected string $view = 'filament.pages.editorial-calendar';
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCalendarDays;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `2026-10`. In the address, so a month can be linked to.
|
||||||
|
*/
|
||||||
|
#[Url]
|
||||||
|
public string $month = '';
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
if (! preg_match('/^\d{4}-\d{2}$/', $this->month)) {
|
||||||
|
$this->month = now()->format('Y-m');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
return Auth::user()?->can('panel.access') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationGroup(): string|UnitEnum|null
|
||||||
|
{
|
||||||
|
return __('panel.groups.content');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('panel.calendar.title');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTitle(): string|Htmlable
|
||||||
|
{
|
||||||
|
return __('panel.calendar.title');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function previous(): void
|
||||||
|
{
|
||||||
|
$this->month = $this->first()->subMonth()->format('Y-m');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function next(): void
|
||||||
|
{
|
||||||
|
$this->month = $this->first()->addMonth()->format('Y-m');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function today(): void
|
||||||
|
{
|
||||||
|
$this->month = now()->format('Y-m');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function first(): CarbonImmutable
|
||||||
|
{
|
||||||
|
return CarbonImmutable::createFromFormat('Y-m-d', $this->month.'-01')->startOfDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return ucfirst($this->first()->locale(app()->getLocale())->isoFormat('MMMM YYYY'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whole weeks, Monday first, covering the month.
|
||||||
|
*
|
||||||
|
* @return array<int, array<int, array{date: CarbonImmutable, inMonth: bool, isToday: bool, events: array<int, array<string, mixed>>}>>
|
||||||
|
*/
|
||||||
|
public function weeks(): array
|
||||||
|
{
|
||||||
|
$start = $this->first()->startOfWeek(CarbonImmutable::MONDAY);
|
||||||
|
$end = $this->first()->endOfMonth()->endOfWeek(CarbonImmutable::SUNDAY);
|
||||||
|
|
||||||
|
$events = Calendar::between($start, $end, Auth::user())->groupBy('date');
|
||||||
|
|
||||||
|
$weeks = [];
|
||||||
|
|
||||||
|
for ($day = $start; $day->lte($end); $day = $day->addDay()) {
|
||||||
|
$weeks[intdiv((int) $start->diffInDays($day), 7)][] = [
|
||||||
|
'date' => $day,
|
||||||
|
'inMonth' => $day->month === $this->first()->month,
|
||||||
|
'isToday' => $day->isToday(),
|
||||||
|
'events' => ($events[$day->toDateString()] ?? collect())->all(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $weeks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function weekdays(): array
|
||||||
|
{
|
||||||
|
$monday = CarbonImmutable::now()->locale(app()->getLocale())->startOfWeek(CarbonImmutable::MONDAY);
|
||||||
|
|
||||||
|
return array_map(fn (int $i) => $monday->addDays($i)->isoFormat('ddd'), range(0, 6));
|
||||||
|
}
|
||||||
|
}
|
||||||
84
app/Filament/Pages/Statistics.php
Normal file
84
app/Filament/Pages/Statistics.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Support\ViewStats;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Pages\Page as FilamentPage;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What people read: the most read, how each language is doing, and what is
|
||||||
|
* on the site that nobody opened. Counts only — what the frontend reports is
|
||||||
|
* a daily total per record and language, with nothing about the person.
|
||||||
|
*/
|
||||||
|
class Statistics extends FilamentPage
|
||||||
|
{
|
||||||
|
public const PERIODS = [7, 30, 90];
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.statistics';
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChartBar;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 6;
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public int $days = 30;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
if (! in_array($this->days, self::PERIODS, true)) {
|
||||||
|
$this->days = 30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedDays(): void
|
||||||
|
{
|
||||||
|
$this->mount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
return Auth::user()?->can('panel.access') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationGroup(): string|UnitEnum|null
|
||||||
|
{
|
||||||
|
return __('panel.groups.content');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('panel.statistics.title');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTitle(): string|Htmlable
|
||||||
|
{
|
||||||
|
return __('panel.statistics.title');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stats(): ViewStats
|
||||||
|
{
|
||||||
|
return new ViewStats($this->days);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "+52 %", "−10 %", or "new" when there was nothing to compare with.
|
||||||
|
*/
|
||||||
|
public function change(int $now, int $before): string
|
||||||
|
{
|
||||||
|
if ($before === 0) {
|
||||||
|
return $now > 0 ? __('panel.statistics.new') : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
$percent = (int) round(($now - $before) * 100 / $before);
|
||||||
|
|
||||||
|
return ($percent > 0 ? '+' : ($percent < 0 ? '−' : '')).abs($percent).' %';
|
||||||
|
}
|
||||||
|
}
|
||||||
244
app/Filament/Pages/TranslationAssistant.php
Normal file
244
app/Filament/Pages/TranslationAssistant.php
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Support\ContentTypes;
|
||||||
|
use App\Support\PendingTranslations;
|
||||||
|
use App\Support\TextDiff;
|
||||||
|
use App\Support\TranslationFreshness;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\RichEditor;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page as FilamentPage;
|
||||||
|
use Filament\Schemas\Components\Grid;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Components\Text;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translating with the original beside you.
|
||||||
|
*
|
||||||
|
* Without a record: what is waiting to be translated, into which language.
|
||||||
|
* With one: each field's original next to the field to write, and for a
|
||||||
|
* translation that fell behind, what the original said when it was translated
|
||||||
|
* and what it says now — the sentence to redo rather than the whole text.
|
||||||
|
*/
|
||||||
|
class TranslationAssistant extends FilamentPage
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Rich text, edited as such; everything else is plain.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
public const RICH = ['body', 'description', 'decisions', 'postmortem'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Never translated here: an address is not a translation, and changing it
|
||||||
|
* has its own consequences on the record's own form.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected const SKIPPED = ['slug', 'featured_image_alt', 'cover_image_alt'];
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.translation-assistant';
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedLanguage;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 7;
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public ?string $type = null;
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public ?string $record = null;
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public ?string $locale = null;
|
||||||
|
|
||||||
|
public ?array $data = [];
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
return Auth::user()?->can('panel.access') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationGroup(): string|UnitEnum|null
|
||||||
|
{
|
||||||
|
return __('panel.groups.content');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('panel.translate.title');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function urlFor(Model $record, ?string $locale = null): string
|
||||||
|
{
|
||||||
|
$type = array_search($record::class, PendingTranslations::TYPES, true);
|
||||||
|
|
||||||
|
return self::getUrl(array_filter(['type' => $type, 'record' => $record->getKey(), 'locale' => $locale]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTitle(): string|Htmlable
|
||||||
|
{
|
||||||
|
$record = $this->target();
|
||||||
|
|
||||||
|
return $record
|
||||||
|
? __('panel.translate.title_for', ['title' => ContentTypes::title($record), 'language' => $this->languageName($this->locale)])
|
||||||
|
: __('panel.translate.title');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$record = $this->target();
|
||||||
|
|
||||||
|
if ($record === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
abort_unless(Auth::user()->can('update', $record), 403);
|
||||||
|
|
||||||
|
$this->form->fill(collect($this->fields($record))
|
||||||
|
->mapWithKeys(fn (string $field) => [$field => $record->getTranslation($field, $this->locale, false)])
|
||||||
|
->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The record being translated, when there is one and the language is one
|
||||||
|
* to translate into.
|
||||||
|
*/
|
||||||
|
public function target(): ?Model
|
||||||
|
{
|
||||||
|
$model = PendingTranslations::TYPES[$this->type] ?? null;
|
||||||
|
$locales = array_keys((array) config('locales.supported'));
|
||||||
|
|
||||||
|
if ($model === null || $this->record === null || ! in_array($this->locale, $locales, true) || $this->locale === config('locales.default')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $model::query()->find($this->record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function pending(): Collection
|
||||||
|
{
|
||||||
|
return PendingTranslations::find(Auth::user());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function targetLocales(): array
|
||||||
|
{
|
||||||
|
return array_values(array_diff(array_keys((array) config('locales.supported')), [config('locales.default')]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function languageName(?string $locale): string
|
||||||
|
{
|
||||||
|
return (string) config("locales.supported.{$locale}.native", $locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
$record = $this->target();
|
||||||
|
|
||||||
|
if ($record === null) {
|
||||||
|
return $schema->statePath('data')->components([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = (string) config('locales.default');
|
||||||
|
$stale = TranslationFreshness::staleFields($record, $this->locale);
|
||||||
|
|
||||||
|
return $schema
|
||||||
|
->statePath('data')
|
||||||
|
->components(collect($this->fields($record))->map(function (string $field) use ($record, $source, $stale) {
|
||||||
|
$original = (string) $record->getTranslation($field, $source, false);
|
||||||
|
$rich = in_array($field, self::RICH, true);
|
||||||
|
$label = __("panel.translate.fields.{$field}");
|
||||||
|
|
||||||
|
$input = match (true) {
|
||||||
|
$rich => RichEditor::make($field)->hiddenLabel(),
|
||||||
|
mb_strlen($original) > 120 => Textarea::make($field)->hiddenLabel()->rows(4),
|
||||||
|
default => TextInput::make($field)->hiddenLabel(),
|
||||||
|
};
|
||||||
|
|
||||||
|
$diff = isset($stale[$field]) ? TextDiff::html($stale[$field]['was'], $original) : null;
|
||||||
|
|
||||||
|
return Section::make($label)
|
||||||
|
->description(isset($stale[$field])
|
||||||
|
? __('panel.translate.stale_since', ['date' => $stale[$field]['since']->isoFormat('LL')])
|
||||||
|
: null)
|
||||||
|
->icon(isset($stale[$field]) ? 'heroicon-o-exclamation-triangle' : null)
|
||||||
|
->iconColor('warning')
|
||||||
|
->compact()
|
||||||
|
->schema(array_filter([
|
||||||
|
$diff ? Text::make(new HtmlString('<div class="ta-diff"><strong>'.e(__('panel.translate.changed')).'</strong> '.$diff.'</div>')) : null,
|
||||||
|
Grid::make(['default' => 1, 'lg' => 2])->schema([
|
||||||
|
Text::make(new HtmlString(
|
||||||
|
'<div class="ta-source"><span class="ta-lang">'.e($this->languageName($source)).'</span>'
|
||||||
|
.($rich ? $original : nl2br(e($original))).'</div>'
|
||||||
|
)),
|
||||||
|
$input,
|
||||||
|
]),
|
||||||
|
]));
|
||||||
|
})->values()->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return $this->target() === null ? [] : [
|
||||||
|
Action::make('back')
|
||||||
|
->label(__('panel.translate.back'))
|
||||||
|
->color('gray')
|
||||||
|
->url(self::getUrl()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$record = $this->target();
|
||||||
|
|
||||||
|
abort_unless($record !== null && Auth::user()->can('update', $record), 403);
|
||||||
|
|
||||||
|
foreach ($this->form->getState() as $field => $value) {
|
||||||
|
if (in_array($field, $this->fields($record), true)) {
|
||||||
|
$record->setTranslation($field, $this->locale, (string) ($value ?? ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->save();
|
||||||
|
|
||||||
|
Notification::make()->title(__('panel.translate.saved'))->success()->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fields worth translating on this record: translatable, not an
|
||||||
|
* address, and with something in the original to translate.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
protected function fields(Model $record): array
|
||||||
|
{
|
||||||
|
$source = (string) config('locales.default');
|
||||||
|
|
||||||
|
return array_values(array_filter(
|
||||||
|
array_diff($record->getTranslatableAttributes(), self::SKIPPED),
|
||||||
|
fn (string $field) => filled($record->getTranslation($field, $source, false)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ class RevisionsRelationManager extends RelationManager
|
|||||||
->color(fn (string $state) => match ($state) {
|
->color(fn (string $state) => match ($state) {
|
||||||
'published' => 'success',
|
'published' => 'success',
|
||||||
'unpublished' => 'warning',
|
'unpublished' => 'warning',
|
||||||
|
'scheduled' => 'info',
|
||||||
'created' => 'info',
|
'created' => 'info',
|
||||||
'deleted', 'erased' => 'danger',
|
'deleted', 'erased' => 'danger',
|
||||||
default => 'gray',
|
default => 'gray',
|
||||||
@@ -77,7 +78,7 @@ class RevisionsRelationManager extends RelationManager
|
|||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('event')
|
SelectFilter::make('event')
|
||||||
->label(__('panel.revisions.fields.event'))
|
->label(__('panel.revisions.fields.event'))
|
||||||
->options(fn () => collect(['created', 'updated', 'published', 'unpublished', 'deleted', 'restored', 'erased'])
|
->options(fn () => collect(['created', 'updated', 'published', 'scheduled', 'unpublished', 'deleted', 'restored', 'erased'])
|
||||||
->mapWithKeys(fn (string $e) => [$e => __("panel.revisions.events.{$e}")])
|
->mapWithKeys(fn (string $e) => [$e => __("panel.revisions.events.{$e}")])
|
||||||
->all()),
|
->all()),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -7,13 +7,19 @@ namespace App\Filament\Resources\Applications\Pages;
|
|||||||
use App\Filament\Resources\Applications\ApplicationResource;
|
use App\Filament\Resources\Applications\ApplicationResource;
|
||||||
use App\Filament\Resources\Applications\Tables\ApplicationsTable;
|
use App\Filament\Resources\Applications\Tables\ApplicationsTable;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
|
use App\Models\ApplicationReply;
|
||||||
|
use App\Models\ReplyTemplate;
|
||||||
|
use App\Support\Inbox\Replier;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Actions\ForceDeleteAction;
|
use Filament\Actions\ForceDeleteAction;
|
||||||
use Filament\Actions\RestoreAction;
|
use Filament\Actions\RestoreAction;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
class ViewApplication extends ViewRecord
|
class ViewApplication extends ViewRecord
|
||||||
{
|
{
|
||||||
@@ -24,10 +30,67 @@ class ViewApplication extends ViewRecord
|
|||||||
return [
|
return [
|
||||||
// Answering is the point of the record, so the reply is the first
|
// Answering is the point of the record, so the reply is the first
|
||||||
// button rather than something to copy out of the page.
|
// button rather than something to copy out of the page.
|
||||||
|
// Answering is the point of the record, so the reply is the first
|
||||||
|
// button: a template to start from, edited here, and sent — by
|
||||||
|
// this application when it can send mail, or handed to the
|
||||||
|
// person's own mail program with the text already written.
|
||||||
Action::make('reply')
|
Action::make('reply')
|
||||||
->label(__('panel.applications.actions.reply'))
|
->label(__('panel.applications.actions.reply'))
|
||||||
->icon('heroicon-o-envelope')
|
->icon('heroicon-o-envelope')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
|
->authorize('update')
|
||||||
|
->modalHeading(fn (Application $record) => __('panel.replies.heading', ['name' => $record->name]))
|
||||||
|
->modalDescription(fn () => Replier::canSendMail() ? __('panel.replies.sends') : __('panel.replies.hands_over'))
|
||||||
|
->modalSubmitActionLabel(fn () => Replier::canSendMail() ? __('panel.replies.send') : __('panel.replies.open_mail'))
|
||||||
|
->schema([
|
||||||
|
Select::make('template_id')
|
||||||
|
->label(__('panel.replies.fields.template'))
|
||||||
|
->options(fn (Application $record) => ReplyTemplate::query()->for($record->kind)->pluck('name', 'id')->all())
|
||||||
|
->placeholder(__('panel.replies.no_template'))
|
||||||
|
->live()
|
||||||
|
->afterStateUpdated(function (?string $state, callable $set, Application $record): void {
|
||||||
|
$template = $state ? ReplyTemplate::find($state) : null;
|
||||||
|
|
||||||
|
if ($template !== null) {
|
||||||
|
$text = $template->renderFor($record, Auth::user());
|
||||||
|
$set('subject', $text['subject']);
|
||||||
|
$set('body', $text['body']);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
TextInput::make('subject')
|
||||||
|
->label(__('panel.replies.fields.subject'))
|
||||||
|
->default(fn (Application $record) => __('panel.applications.reply_subject', ['subject' => $record->subject()], $record->locale))
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
|
||||||
|
Textarea::make('body')
|
||||||
|
->label(__('panel.replies.fields.body'))
|
||||||
|
->helperText(fn (Application $record) => __('panel.replies.in_language', [
|
||||||
|
'language' => config("locales.supported.{$record->locale}.native", $record->locale),
|
||||||
|
]))
|
||||||
|
->required()
|
||||||
|
->rows(12),
|
||||||
|
])
|
||||||
|
->action(function (Application $record, array $data, $livewire): void {
|
||||||
|
$reply = Replier::reply($record, $data['subject'], $data['body'], Auth::user());
|
||||||
|
|
||||||
|
if ($reply->channel === ApplicationReply::MAILTO) {
|
||||||
|
$livewire->js('window.location.href = '.json_encode(Replier::mailto($record, $data['subject'], $data['body'])));
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title($reply->channel === ApplicationReply::MAIL ? __('panel.replies.sent') : __('panel.replies.recorded'))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
|
||||||
|
// The way it always worked: an empty message in your own mail
|
||||||
|
// program, for an answer that fits no template.
|
||||||
|
Action::make('reply_elsewhere')
|
||||||
|
->label(__('panel.replies.elsewhere'))
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
->url(fn (Application $record) => 'mailto:'.$record->email.'?subject='
|
->url(fn (Application $record) => 'mailto:'.$record->email.'?subject='
|
||||||
.rawurlencode(__('panel.applications.reply_subject', [
|
.rawurlencode(__('panel.applications.reply_subject', [
|
||||||
'subject' => $record->subject(),
|
'subject' => $record->subject(),
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources\Applications\Schemas;
|
namespace App\Filament\Resources\Applications\Schemas;
|
||||||
|
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
|
use App\Models\ApplicationReply;
|
||||||
use Filament\Infolists\Components\KeyValueEntry;
|
use Filament\Infolists\Components\KeyValueEntry;
|
||||||
|
use Filament\Infolists\Components\RepeatableEntry;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
@@ -69,6 +71,36 @@ class ApplicationInfolist
|
|||||||
->visible(fn (Application $record) => filled($record->links)),
|
->visible(fn (Application $record) => filled($record->links)),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
// What was already said, so the next person to open this does not
|
||||||
|
// say it again.
|
||||||
|
Section::make(__('panel.replies.history'))
|
||||||
|
->visible(fn (Application $record) => $record->replies()->exists())
|
||||||
|
->schema([
|
||||||
|
RepeatableEntry::make('replies')
|
||||||
|
->hiddenLabel()
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('subject')
|
||||||
|
->hiddenLabel()
|
||||||
|
->weight('bold'),
|
||||||
|
|
||||||
|
TextEntry::make('created_at')
|
||||||
|
->hiddenLabel()
|
||||||
|
->state(fn (ApplicationReply $record) => __('panel.replies.by', [
|
||||||
|
'name' => $record->user_name ?? '—',
|
||||||
|
'when' => $record->created_at?->isoFormat('LLL'),
|
||||||
|
]).' · '.__('panel.replies.channels.'.$record->channel))
|
||||||
|
->color('gray'),
|
||||||
|
|
||||||
|
TextEntry::make('body')
|
||||||
|
->hiddenLabel()
|
||||||
|
->prose()
|
||||||
|
->formatStateUsing(fn (string $state) => nl2br(e($state)))
|
||||||
|
->html()
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
]),
|
||||||
|
|
||||||
Section::make(__('panel.applications.sections.internal'))
|
Section::make(__('panel.applications.sections.internal'))
|
||||||
->description(__('panel.applications.sections.internal_hint'))
|
->description(__('panel.applications.sections.internal_hint'))
|
||||||
->columns(4)
|
->columns(4)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Filament\Resources\Applications\Tables;
|
|||||||
|
|
||||||
use App\Enums\ApplicationKind;
|
use App\Enums\ApplicationKind;
|
||||||
use App\Enums\ApplicationStatus;
|
use App\Enums\ApplicationStatus;
|
||||||
|
use App\Filament\Support\TranslatedOptionLabel;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
@@ -93,6 +94,7 @@ class ApplicationsTable
|
|||||||
SelectFilter::make('call')
|
SelectFilter::make('call')
|
||||||
->label(__('panel.applications.fields.call'))
|
->label(__('panel.applications.fields.call'))
|
||||||
->relationship('call', 'title->'.config('locales.default'))
|
->relationship('call', 'title->'.config('locales.default'))
|
||||||
|
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload(),
|
->preload(),
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ use App\Filament\Resources\Calls\Pages\CreateCall;
|
|||||||
use App\Filament\Resources\Calls\Pages\EditCall;
|
use App\Filament\Resources\Calls\Pages\EditCall;
|
||||||
use App\Filament\Resources\Calls\Pages\ListCalls;
|
use App\Filament\Resources\Calls\Pages\ListCalls;
|
||||||
use App\Filament\Resources\Calls\RelationManagers\ApplicationsRelationManager;
|
use App\Filament\Resources\Calls\RelationManagers\ApplicationsRelationManager;
|
||||||
|
use App\Filament\Support\DuplicateAction;
|
||||||
use App\Filament\Support\PreviewAction;
|
use App\Filament\Support\PreviewAction;
|
||||||
use App\Filament\Support\TranslatableTabs;
|
use App\Filament\Support\TranslatableTabs;
|
||||||
|
use App\Filament\Support\TranslatedOptionLabel;
|
||||||
use App\Models\Call;
|
use App\Models\Call;
|
||||||
use App\Rules\UniqueTranslatedSlug;
|
use App\Rules\UniqueTranslatedSlug;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
@@ -19,7 +21,6 @@ use Filament\Actions\BulkActionGroup;
|
|||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
use Filament\Actions\ReplicateAction;
|
|
||||||
use Filament\Forms\Components\DatePicker;
|
use Filament\Forms\Components\DatePicker;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
@@ -141,6 +142,7 @@ class CallResource extends Resource
|
|||||||
Select::make('project_id')
|
Select::make('project_id')
|
||||||
->label(__('panel.calls.fields.project'))
|
->label(__('panel.calls.fields.project'))
|
||||||
->relationship('project', 'title->'.config('locales.default'))
|
->relationship('project', 'title->'.config('locales.default'))
|
||||||
|
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload()
|
->preload()
|
||||||
->helperText(__('panel.calls.fields.project_hint')),
|
->helperText(__('panel.calls.fields.project_hint')),
|
||||||
@@ -228,17 +230,9 @@ class CallResource extends Resource
|
|||||||
->preload(),
|
->preload(),
|
||||||
])
|
])
|
||||||
->recordActions([
|
->recordActions([
|
||||||
// A near-copy is a real workflow: the third similar service,
|
|
||||||
// next year's call. The slug is cleared because two records
|
|
||||||
// cannot answer at the same URL.
|
|
||||||
ReplicateAction::make()
|
|
||||||
->label(__('panel.actions.duplicate'))
|
|
||||||
->icon('heroicon-o-document-duplicate')
|
|
||||||
->excludeAttributes(['slug', 'published_at', 'unpublish_at'])
|
|
||||||
->modalHeading(__('panel.actions.duplicate_heading'))
|
|
||||||
->successNotificationTitle(__('panel.actions.duplicated')),
|
|
||||||
|
|
||||||
PreviewAction::for('call'),
|
PreviewAction::for('call'),
|
||||||
|
DuplicateAction::make(),
|
||||||
|
|
||||||
EditAction::make(),
|
EditAction::make(),
|
||||||
DeleteAction::make(),
|
DeleteAction::make(),
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ namespace App\Filament\Resources\Calls\Pages;
|
|||||||
|
|
||||||
use App\Filament\Concerns\WarnsOfConcurrentEditing;
|
use App\Filament\Concerns\WarnsOfConcurrentEditing;
|
||||||
use App\Filament\Resources\Calls\CallResource;
|
use App\Filament\Resources\Calls\CallResource;
|
||||||
|
use App\Filament\Support\DuplicateAction;
|
||||||
|
use App\Filament\Support\PreviewAction;
|
||||||
|
use App\Filament\Support\TranslateAction;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
@@ -18,6 +21,10 @@ class EditCall extends EditRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
// Seeing it on the real site without leaving the form.
|
||||||
|
PreviewAction::for('call'),
|
||||||
|
DuplicateAction::make(),
|
||||||
|
TranslateAction::make(),
|
||||||
DeleteAction::make(),
|
DeleteAction::make(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ namespace App\Filament\Resources\Pages\Pages;
|
|||||||
use App\Enums\PageStatus;
|
use App\Enums\PageStatus;
|
||||||
use App\Filament\Concerns\WarnsOfConcurrentEditing;
|
use App\Filament\Concerns\WarnsOfConcurrentEditing;
|
||||||
use App\Filament\Resources\Pages\PageResource;
|
use App\Filament\Resources\Pages\PageResource;
|
||||||
|
use App\Filament\Support\DuplicateAction;
|
||||||
|
use App\Filament\Support\PreviewAction;
|
||||||
|
use App\Filament\Support\TranslateAction;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Actions\ForceDeleteAction;
|
use Filament\Actions\ForceDeleteAction;
|
||||||
use Filament\Actions\RestoreAction;
|
use Filament\Actions\RestoreAction;
|
||||||
@@ -21,6 +24,10 @@ class EditPage extends EditRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
// Seeing it on the real site without leaving the form.
|
||||||
|
PreviewAction::for('page'),
|
||||||
|
DuplicateAction::make(),
|
||||||
|
TranslateAction::make(),
|
||||||
DeleteAction::make(),
|
DeleteAction::make(),
|
||||||
ForceDeleteAction::make(),
|
ForceDeleteAction::make(),
|
||||||
RestoreAction::make(),
|
RestoreAction::make(),
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ namespace App\Filament\Resources\Pages\Schemas;
|
|||||||
use App\Enums\PageStatus;
|
use App\Enums\PageStatus;
|
||||||
use App\Filament\Resources\Pages\PageResource;
|
use App\Filament\Resources\Pages\PageResource;
|
||||||
use App\Filament\Support\TranslatableTabs;
|
use App\Filament\Support\TranslatableTabs;
|
||||||
|
use App\Filament\Support\TranslatedOptionLabel;
|
||||||
use App\Models\Page;
|
use App\Models\Page;
|
||||||
|
use App\Models\Tag;
|
||||||
use App\Rules\UniqueTranslatedSlug;
|
use App\Rules\UniqueTranslatedSlug;
|
||||||
use App\Support\DuplicateTitles;
|
use App\Support\DuplicateTitles;
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
@@ -74,6 +76,15 @@ class PageForm
|
|||||||
|
|
||||||
RichEditor::make("body.{$locale}")
|
RichEditor::make("body.{$locale}")
|
||||||
->label(__('panel.pages.fields.body'))
|
->label(__('panel.pages.fields.body'))
|
||||||
|
// Images in the text go where every other upload
|
||||||
|
// does, public: the HTML travels to the frontend
|
||||||
|
// as it is, with each image's URL written in, and
|
||||||
|
// a private file would be a broken image there.
|
||||||
|
// Media::referencedPaths() finds them in the body,
|
||||||
|
// so pruning never takes one a page still shows.
|
||||||
|
->fileAttachmentsDisk(config('media.disk'))
|
||||||
|
->fileAttachmentsDirectory('pages/content')
|
||||||
|
->fileAttachmentsVisibility('public')
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
@@ -112,6 +123,40 @@ class PageForm
|
|||||||
Select::make('category_id')
|
Select::make('category_id')
|
||||||
->label(__('panel.pages.fields.category'))
|
->label(__('panel.pages.fields.category'))
|
||||||
->relationship('category', 'name->'.config('locales.default'))
|
->relationship('category', 'name->'.config('locales.default'))
|
||||||
|
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('name'))
|
||||||
|
->searchable()
|
||||||
|
->preload(),
|
||||||
|
|
||||||
|
// What the post is about, for the blog to be read by
|
||||||
|
// subject. A missing one is made from here.
|
||||||
|
Select::make('tags')
|
||||||
|
->label(__('panel.tags.plural'))
|
||||||
|
->relationship('tags', 'name->'.config('locales.default'))
|
||||||
|
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('name'))
|
||||||
|
->multiple()
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->createOptionForm([
|
||||||
|
TextInput::make('name')
|
||||||
|
->label(__('panel.tags.fields.name'))
|
||||||
|
->required()
|
||||||
|
->maxLength(80),
|
||||||
|
])
|
||||||
|
->createOptionUsing(function (array $data): int {
|
||||||
|
$locale = (string) config('locales.default');
|
||||||
|
|
||||||
|
return Tag::query()->create([
|
||||||
|
'name' => [$locale => $data['name']],
|
||||||
|
'slug' => [$locale => Str::slug($data['name'])],
|
||||||
|
])->getKey();
|
||||||
|
})
|
||||||
|
->createOptionAction(fn ($action) => $action->authorize('create', Tag::class)),
|
||||||
|
|
||||||
|
// Who signs it on the site, with a link to their profile.
|
||||||
|
Select::make('byline_member_id')
|
||||||
|
->label(__('panel.pages.fields.byline'))
|
||||||
|
->helperText(__('panel.pages.fields.byline_hint'))
|
||||||
|
->relationship('byline', 'name')
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload(),
|
->preload(),
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources\Pages\Tables;
|
namespace App\Filament\Resources\Pages\Tables;
|
||||||
|
|
||||||
use App\Enums\PageStatus;
|
use App\Enums\PageStatus;
|
||||||
|
use App\Filament\Support\DuplicateAction;
|
||||||
use App\Filament\Support\PreviewAction;
|
use App\Filament\Support\PreviewAction;
|
||||||
use App\Filament\Support\ReviewActions;
|
use App\Filament\Support\ReviewActions;
|
||||||
use App\Filament\Support\ReviewStatusColumn;
|
use App\Filament\Support\ReviewStatusColumn;
|
||||||
|
use App\Filament\Support\TranslatedOptionLabel;
|
||||||
use App\Filament\Support\TranslationStatusColumn;
|
use App\Filament\Support\TranslationStatusColumn;
|
||||||
use App\Filament\Support\ViewsColumn;
|
use App\Filament\Support\ViewsColumn;
|
||||||
use App\Models\Page;
|
use App\Models\Page;
|
||||||
@@ -19,7 +21,6 @@ use Filament\Actions\DeleteAction;
|
|||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
use Filament\Actions\ForceDeleteBulkAction;
|
use Filament\Actions\ForceDeleteBulkAction;
|
||||||
use Filament\Actions\ReplicateAction;
|
|
||||||
use Filament\Actions\RestoreBulkAction;
|
use Filament\Actions\RestoreBulkAction;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Tables\Columns\IconColumn;
|
use Filament\Tables\Columns\IconColumn;
|
||||||
@@ -127,6 +128,7 @@ class PagesTable
|
|||||||
SelectFilter::make('category')
|
SelectFilter::make('category')
|
||||||
->label(__('panel.pages.fields.category'))
|
->label(__('panel.pages.fields.category'))
|
||||||
->relationship('category', 'name->'.config('locales.default'))
|
->relationship('category', 'name->'.config('locales.default'))
|
||||||
|
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('name'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload(),
|
->preload(),
|
||||||
|
|
||||||
@@ -209,17 +211,8 @@ class PagesTable
|
|||||||
&& Auth::user()?->can('publish', $record))
|
&& Auth::user()?->can('publish', $record))
|
||||||
->action(fn (Page $record) => $record->update(['status' => PageStatus::Draft])),
|
->action(fn (Page $record) => $record->update(['status' => PageStatus::Draft])),
|
||||||
|
|
||||||
// A near-copy is a real workflow: the third similar service,
|
|
||||||
// next year's call. The slug is cleared because two records
|
|
||||||
// cannot answer at the same URL.
|
|
||||||
ReplicateAction::make()
|
|
||||||
->label(__('panel.actions.duplicate'))
|
|
||||||
->icon('heroicon-o-document-duplicate')
|
|
||||||
->excludeAttributes(['slug', 'published_at', 'unpublish_at'])
|
|
||||||
->modalHeading(__('panel.actions.duplicate_heading'))
|
|
||||||
->successNotificationTitle(__('panel.actions.duplicated')),
|
|
||||||
|
|
||||||
PreviewAction::for('page'),
|
PreviewAction::for('page'),
|
||||||
|
DuplicateAction::make(),
|
||||||
|
|
||||||
EditAction::make(),
|
EditAction::make(),
|
||||||
DeleteAction::make(),
|
DeleteAction::make(),
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ namespace App\Filament\Resources\Projects\Pages;
|
|||||||
|
|
||||||
use App\Filament\Concerns\WarnsOfConcurrentEditing;
|
use App\Filament\Concerns\WarnsOfConcurrentEditing;
|
||||||
use App\Filament\Resources\Projects\ProjectResource;
|
use App\Filament\Resources\Projects\ProjectResource;
|
||||||
|
use App\Filament\Support\DuplicateAction;
|
||||||
|
use App\Filament\Support\PreviewAction;
|
||||||
|
use App\Filament\Support\TranslateAction;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
@@ -18,6 +21,10 @@ class EditProject extends EditRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
// Seeing it on the real site without leaving the form.
|
||||||
|
PreviewAction::for('project'),
|
||||||
|
DuplicateAction::make(),
|
||||||
|
TranslateAction::make(),
|
||||||
DeleteAction::make(),
|
DeleteAction::make(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources\Projects\Tables;
|
namespace App\Filament\Resources\Projects\Tables;
|
||||||
|
|
||||||
use App\Enums\ProjectStatus;
|
use App\Enums\ProjectStatus;
|
||||||
|
use App\Filament\Support\DuplicateAction;
|
||||||
use App\Filament\Support\PreviewAction;
|
use App\Filament\Support\PreviewAction;
|
||||||
use App\Filament\Support\ReviewActions;
|
use App\Filament\Support\ReviewActions;
|
||||||
use App\Filament\Support\ReviewStatusColumn;
|
use App\Filament\Support\ReviewStatusColumn;
|
||||||
@@ -19,7 +20,6 @@ use Filament\Actions\DeleteAction;
|
|||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
use Filament\Actions\ForceDeleteBulkAction;
|
use Filament\Actions\ForceDeleteBulkAction;
|
||||||
use Filament\Actions\ReplicateAction;
|
|
||||||
use Filament\Actions\RestoreBulkAction;
|
use Filament\Actions\RestoreBulkAction;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Tables\Columns\IconColumn;
|
use Filament\Tables\Columns\IconColumn;
|
||||||
@@ -197,17 +197,8 @@ class ProjectsTable
|
|||||||
&& Auth::user()?->can('publish', $record))
|
&& Auth::user()?->can('publish', $record))
|
||||||
->action(fn (Project $record) => $record->update(['published_at' => now()])),
|
->action(fn (Project $record) => $record->update(['published_at' => now()])),
|
||||||
|
|
||||||
// A near-copy is a real workflow: the third similar service,
|
|
||||||
// next year's call. The slug is cleared because two records
|
|
||||||
// cannot answer at the same URL.
|
|
||||||
ReplicateAction::make()
|
|
||||||
->label(__('panel.actions.duplicate'))
|
|
||||||
->icon('heroicon-o-document-duplicate')
|
|
||||||
->excludeAttributes(['slug', 'published_at', 'unpublish_at'])
|
|
||||||
->modalHeading(__('panel.actions.duplicate_heading'))
|
|
||||||
->successNotificationTitle(__('panel.actions.duplicated')),
|
|
||||||
|
|
||||||
PreviewAction::for('project'),
|
PreviewAction::for('project'),
|
||||||
|
DuplicateAction::make(),
|
||||||
|
|
||||||
EditAction::make(),
|
EditAction::make(),
|
||||||
DeleteAction::make(),
|
DeleteAction::make(),
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\ReplyTemplates\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\ReplyTemplates\ReplyTemplateResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateReplyTemplate extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = ReplyTemplateResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\ReplyTemplates\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\ReplyTemplates\ReplyTemplateResource;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditReplyTemplate extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = ReplyTemplateResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
DeleteAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\ReplyTemplates\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\ReplyTemplates\ReplyTemplateResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListReplyTemplates extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = ReplyTemplateResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
131
app/Filament/Resources/ReplyTemplates/ReplyTemplateResource.php
Normal file
131
app/Filament/Resources/ReplyTemplates/ReplyTemplateResource.php
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\ReplyTemplates;
|
||||||
|
|
||||||
|
use App\Enums\ApplicationKind;
|
||||||
|
use App\Filament\Resources\ReplyTemplates\Pages\CreateReplyTemplate;
|
||||||
|
use App\Filament\Resources\ReplyTemplates\Pages\EditReplyTemplate;
|
||||||
|
use App\Filament\Resources\ReplyTemplates\Pages\ListReplyTemplates;
|
||||||
|
use App\Filament\Support\TranslatableTabs;
|
||||||
|
use App\Models\ReplyTemplate;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The answers the inbox gives often enough to write once.
|
||||||
|
*/
|
||||||
|
class ReplyTemplateResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = ReplyTemplate::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChatBubbleBottomCenterText;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 5;
|
||||||
|
|
||||||
|
protected static ?string $recordTitleAttribute = 'name';
|
||||||
|
|
||||||
|
public static function getModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('panel.replies.templates.label');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPluralModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('panel.replies.templates.plural');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationGroup(): string|UnitEnum|null
|
||||||
|
{
|
||||||
|
return __('panel.groups.collective');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->components([
|
||||||
|
Section::make()
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
TextInput::make('name')
|
||||||
|
->label(__('panel.replies.templates.fields.name'))
|
||||||
|
->helperText(__('panel.replies.templates.fields.name_hint'))
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
|
||||||
|
Select::make('kind')
|
||||||
|
->label(__('panel.replies.templates.fields.kind'))
|
||||||
|
->options(ApplicationKind::options())
|
||||||
|
->placeholder(__('panel.replies.templates.any_kind')),
|
||||||
|
|
||||||
|
TextInput::make('position')
|
||||||
|
->label(__('panel.replies.templates.fields.position'))
|
||||||
|
->numeric()
|
||||||
|
->minValue(0)
|
||||||
|
->default(0),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make()
|
||||||
|
->description(__('panel.replies.templates.placeholders', ['list' => implode(', ', ReplyTemplate::PLACEHOLDERS)]))
|
||||||
|
->schema([
|
||||||
|
TranslatableTabs::make(fn (string $locale, bool $isDefault) => [
|
||||||
|
TextInput::make("subject.{$locale}")
|
||||||
|
->label(__('panel.replies.fields.subject'))
|
||||||
|
->required($isDefault)
|
||||||
|
->maxLength(255),
|
||||||
|
|
||||||
|
Textarea::make("body.{$locale}")
|
||||||
|
->label(__('panel.replies.fields.body'))
|
||||||
|
->required($isDefault)
|
||||||
|
->rows(10),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->defaultSort('position')
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('name')
|
||||||
|
->label(__('panel.replies.templates.fields.name'))
|
||||||
|
->searchable()
|
||||||
|
->weight('bold'),
|
||||||
|
|
||||||
|
TextColumn::make('kind')
|
||||||
|
->label(__('panel.replies.templates.fields.kind'))
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (?string $state) => $state ? ApplicationKind::from($state)->getLabel() : __('panel.replies.templates.any_kind'))
|
||||||
|
->placeholder(__('panel.replies.templates.any_kind')),
|
||||||
|
|
||||||
|
TextColumn::make('subject')
|
||||||
|
->label(__('panel.replies.fields.subject'))
|
||||||
|
->limit(60),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
EditAction::make(),
|
||||||
|
DeleteAction::make(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListReplyTemplates::route('/'),
|
||||||
|
'create' => CreateReplyTemplate::route('/create'),
|
||||||
|
'edit' => EditReplyTemplate::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use App\Filament\Resources\Repositories\Pages\CreateRepository;
|
|||||||
use App\Filament\Resources\Repositories\Pages\EditRepository;
|
use App\Filament\Resources\Repositories\Pages\EditRepository;
|
||||||
use App\Filament\Resources\Repositories\Pages\ListRepositories;
|
use App\Filament\Resources\Repositories\Pages\ListRepositories;
|
||||||
use App\Filament\Resources\Repositories\Tables\RepositoriesTable;
|
use App\Filament\Resources\Repositories\Tables\RepositoriesTable;
|
||||||
|
use App\Filament\Support\TranslatedOptionLabel;
|
||||||
use App\Models\Repository;
|
use App\Models\Repository;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
@@ -81,6 +82,7 @@ class RepositoryResource extends Resource
|
|||||||
Select::make('project_id')
|
Select::make('project_id')
|
||||||
->label(__('panel.repositories.fields.project'))
|
->label(__('panel.repositories.fields.project'))
|
||||||
->relationship('project', 'title->'.config('locales.default'))
|
->relationship('project', 'title->'.config('locales.default'))
|
||||||
|
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload(),
|
->preload(),
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ namespace App\Filament\Resources\Services\Pages;
|
|||||||
|
|
||||||
use App\Filament\Concerns\WarnsOfConcurrentEditing;
|
use App\Filament\Concerns\WarnsOfConcurrentEditing;
|
||||||
use App\Filament\Resources\Services\ServiceResource;
|
use App\Filament\Resources\Services\ServiceResource;
|
||||||
|
use App\Filament\Support\DuplicateAction;
|
||||||
|
use App\Filament\Support\PreviewAction;
|
||||||
|
use App\Filament\Support\TranslateAction;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
@@ -18,6 +21,10 @@ class EditService extends EditRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
// Seeing it on the real site without leaving the form.
|
||||||
|
PreviewAction::for('service'),
|
||||||
|
DuplicateAction::make(),
|
||||||
|
TranslateAction::make(),
|
||||||
DeleteAction::make(),
|
DeleteAction::make(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Filament\RelationManagers\RevisionsRelationManager;
|
|||||||
use App\Filament\Resources\Services\Pages\CreateService;
|
use App\Filament\Resources\Services\Pages\CreateService;
|
||||||
use App\Filament\Resources\Services\Pages\EditService;
|
use App\Filament\Resources\Services\Pages\EditService;
|
||||||
use App\Filament\Resources\Services\Pages\ListServices;
|
use App\Filament\Resources\Services\Pages\ListServices;
|
||||||
|
use App\Filament\Support\DuplicateAction;
|
||||||
use App\Filament\Support\PreviewAction;
|
use App\Filament\Support\PreviewAction;
|
||||||
use App\Filament\Support\ReviewActions;
|
use App\Filament\Support\ReviewActions;
|
||||||
use App\Filament\Support\ReviewStatusColumn;
|
use App\Filament\Support\ReviewStatusColumn;
|
||||||
@@ -26,7 +27,6 @@ use Filament\Actions\DeleteAction;
|
|||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
use Filament\Actions\ForceDeleteBulkAction;
|
use Filament\Actions\ForceDeleteBulkAction;
|
||||||
use Filament\Actions\ReplicateAction;
|
|
||||||
use Filament\Actions\RestoreBulkAction;
|
use Filament\Actions\RestoreBulkAction;
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
@@ -323,17 +323,8 @@ class ServiceResource extends Resource
|
|||||||
ReviewActions::request(),
|
ReviewActions::request(),
|
||||||
ReviewActions::returnForChanges(),
|
ReviewActions::returnForChanges(),
|
||||||
|
|
||||||
// A near-copy is a real workflow: the third similar service,
|
|
||||||
// next year's call. The slug is cleared because two records
|
|
||||||
// cannot answer at the same URL.
|
|
||||||
ReplicateAction::make()
|
|
||||||
->label(__('panel.actions.duplicate'))
|
|
||||||
->icon('heroicon-o-document-duplicate')
|
|
||||||
->excludeAttributes(['slug', 'published_at', 'unpublish_at'])
|
|
||||||
->modalHeading(__('panel.actions.duplicate_heading'))
|
|
||||||
->successNotificationTitle(__('panel.actions.duplicated')),
|
|
||||||
|
|
||||||
PreviewAction::for('service'),
|
PreviewAction::for('service'),
|
||||||
|
DuplicateAction::make(),
|
||||||
|
|
||||||
EditAction::make(),
|
EditAction::make(),
|
||||||
DeleteAction::make(),
|
DeleteAction::make(),
|
||||||
|
|||||||
21
app/Filament/Resources/Tags/Pages/ManageTags.php
Normal file
21
app/Filament/Resources/Tags/Pages/ManageTags.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Tags\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Tags\TagResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ManageRecords;
|
||||||
|
|
||||||
|
class ManageTags extends ManageRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = TagResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
108
app/Filament/Resources/Tags/TagResource.php
Normal file
108
app/Filament/Resources/Tags/TagResource.php
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Tags;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Tags\Pages\ManageTags;
|
||||||
|
use App\Filament\Support\TranslatableTabs;
|
||||||
|
use App\Models\Tag;
|
||||||
|
use App\Rules\UniqueTranslatedSlug;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blog tags: what each post is about, named in every language.
|
||||||
|
*/
|
||||||
|
class TagResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Tag::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedHashtag;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 3;
|
||||||
|
|
||||||
|
public static function getModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('panel.tags.label');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPluralModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('panel.tags.plural');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationGroup(): string|UnitEnum|null
|
||||||
|
{
|
||||||
|
return __('panel.groups.content');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRecordTitle(?Model $record): ?string
|
||||||
|
{
|
||||||
|
return $record?->getTranslation('name', app()->getLocale());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->components([
|
||||||
|
TranslatableTabs::make(fn (string $locale, bool $isDefault) => [
|
||||||
|
TextInput::make("name.{$locale}")
|
||||||
|
->label(__('panel.tags.fields.name'))
|
||||||
|
->required($isDefault)
|
||||||
|
->maxLength(80)
|
||||||
|
->live(onBlur: true)
|
||||||
|
->afterStateUpdated(function (?string $state, callable $get, callable $set) use ($locale): void {
|
||||||
|
if (blank($get("slug.{$locale}"))) {
|
||||||
|
$set("slug.{$locale}", Str::slug((string) $state));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
TextInput::make("slug.{$locale}")
|
||||||
|
->label(__('panel.tags.fields.slug'))
|
||||||
|
->required($isDefault)
|
||||||
|
->maxLength(80)
|
||||||
|
->rules(fn ($record) => [new UniqueTranslatedSlug(Tag::class, $record?->id)]),
|
||||||
|
])->columnSpanFull(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->modifyQueryUsing(fn ($query) => $query->withCount('pages'))
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('name')
|
||||||
|
->label(__('panel.tags.fields.name'))
|
||||||
|
->searchable(),
|
||||||
|
|
||||||
|
TextColumn::make('slug')
|
||||||
|
->label(__('panel.tags.fields.slug'))
|
||||||
|
->color('gray'),
|
||||||
|
|
||||||
|
TextColumn::make('pages_count')
|
||||||
|
->label(__('panel.tags.fields.pages'))
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
EditAction::make(),
|
||||||
|
DeleteAction::make(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ManageTags::route('/'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use App\Filament\Resources\Talks\Pages\CreateTalk;
|
|||||||
use App\Filament\Resources\Talks\Pages\EditTalk;
|
use App\Filament\Resources\Talks\Pages\EditTalk;
|
||||||
use App\Filament\Resources\Talks\Pages\ListTalks;
|
use App\Filament\Resources\Talks\Pages\ListTalks;
|
||||||
use App\Filament\Support\TranslatableTabs;
|
use App\Filament\Support\TranslatableTabs;
|
||||||
|
use App\Filament\Support\TranslatedOptionLabel;
|
||||||
use App\Models\Talk;
|
use App\Models\Talk;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
@@ -126,6 +127,7 @@ class TalkResource extends Resource
|
|||||||
Select::make('project_id')
|
Select::make('project_id')
|
||||||
->label(__('panel.talks.fields.project'))
|
->label(__('panel.talks.fields.project'))
|
||||||
->relationship('project', 'title->'.config('locales.default'))
|
->relationship('project', 'title->'.config('locales.default'))
|
||||||
|
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload(),
|
->preload(),
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Filament\Resources\Testimonials\Pages\CreateTestimonial;
|
|||||||
use App\Filament\Resources\Testimonials\Pages\EditTestimonial;
|
use App\Filament\Resources\Testimonials\Pages\EditTestimonial;
|
||||||
use App\Filament\Resources\Testimonials\Pages\ListTestimonials;
|
use App\Filament\Resources\Testimonials\Pages\ListTestimonials;
|
||||||
use App\Filament\Support\TranslatableTabs;
|
use App\Filament\Support\TranslatableTabs;
|
||||||
|
use App\Filament\Support\TranslatedOptionLabel;
|
||||||
use App\Models\Testimonial;
|
use App\Models\Testimonial;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
@@ -137,12 +138,14 @@ class TestimonialResource extends Resource
|
|||||||
Select::make('project_id')
|
Select::make('project_id')
|
||||||
->label(__('panel.testimonials.fields.project'))
|
->label(__('panel.testimonials.fields.project'))
|
||||||
->relationship('project', 'title->'.config('locales.default'))
|
->relationship('project', 'title->'.config('locales.default'))
|
||||||
|
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload(),
|
->preload(),
|
||||||
|
|
||||||
Select::make('service_id')
|
Select::make('service_id')
|
||||||
->label(__('panel.testimonials.fields.service'))
|
->label(__('panel.testimonials.fields.service'))
|
||||||
->relationship('service', 'title->'.config('locales.default'))
|
->relationship('service', 'title->'.config('locales.default'))
|
||||||
|
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload(),
|
->preload(),
|
||||||
|
|
||||||
|
|||||||
38
app/Filament/Support/DuplicateAction.php
Normal file
38
app/Filament/Support/DuplicateAction.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Support;
|
||||||
|
|
||||||
|
use App\Support\Duplicator;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Duplicate": a draft copy to start the next one from, opened straight away.
|
||||||
|
*/
|
||||||
|
final class DuplicateAction
|
||||||
|
{
|
||||||
|
public static function make(): Action
|
||||||
|
{
|
||||||
|
return Action::make('duplicate')
|
||||||
|
->label(__('panel.duplicate.label'))
|
||||||
|
->icon('heroicon-o-document-duplicate')
|
||||||
|
->color('gray')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading(__('panel.duplicate.heading'))
|
||||||
|
->modalDescription(__('panel.duplicate.hint'))
|
||||||
|
->authorize('create')
|
||||||
|
->action(function (Model $record, $livewire): void {
|
||||||
|
$copy = Duplicator::duplicate($record);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title(__('panel.duplicate.done'))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$livewire->redirect($livewire::getResource()::getUrl('edit', ['record' => $copy]));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Filament/Support/TranslateAction.php
Normal file
34
app/Filament/Support/TranslateAction.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Support;
|
||||||
|
|
||||||
|
use App\Filament\Pages\TranslationAssistant;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Translate into…": this record in the translation assistant, one entry per
|
||||||
|
* language there is to translate into.
|
||||||
|
*/
|
||||||
|
final class TranslateAction
|
||||||
|
{
|
||||||
|
public static function make(): ActionGroup
|
||||||
|
{
|
||||||
|
$default = (string) config('locales.default');
|
||||||
|
|
||||||
|
return ActionGroup::make(collect(config('locales.supported'))
|
||||||
|
->except($default)
|
||||||
|
->map(fn (array $meta, string $locale) => Action::make("translate_{$locale}")
|
||||||
|
->label($meta['native'] ?? $locale)
|
||||||
|
->url(fn (Model $record) => TranslationAssistant::urlFor($record, $locale)))
|
||||||
|
->values()
|
||||||
|
->all())
|
||||||
|
->label(__('panel.translate.action'))
|
||||||
|
->icon('heroicon-o-language')
|
||||||
|
->color('gray')
|
||||||
|
->button();
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Filament/Support/TranslatedOptionLabel.php
Normal file
29
app/Filament/Support/TranslatedOptionLabel.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Support;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The label of a relationship option whose title is translatable.
|
||||||
|
*
|
||||||
|
* `->relationship('category', 'name->es')` builds the list fine — that path
|
||||||
|
* goes through a query — but to validate a choice Filament reads the title
|
||||||
|
* back off the record as a property, `$record->{'name->es'}`, which does not
|
||||||
|
* exist. Every choice then came back without a label and was refused as "not
|
||||||
|
* in the list of allowed values", whichever one it was. Given this, Filament
|
||||||
|
* asks the record instead, in the language of whoever is using the panel.
|
||||||
|
*
|
||||||
|
* The JSON path stays in `relationship()`: it is still what the search box
|
||||||
|
* queries and the list is ordered by.
|
||||||
|
*/
|
||||||
|
final class TranslatedOptionLabel
|
||||||
|
{
|
||||||
|
public static function using(string $attribute): Closure
|
||||||
|
{
|
||||||
|
return fn (Model $record): ?string => $record->getTranslation($attribute, app()->getLocale());
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Filament/Widgets/ExpiringSoon.php
Normal file
40
app/Filament/Widgets/ExpiringSoon.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Widgets;
|
||||||
|
|
||||||
|
use App\Support\Expiring;
|
||||||
|
use Filament\Widgets\Widget;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Coming up": calls closing and content coming off the site in the next two
|
||||||
|
* weeks, for whoever can do something about them.
|
||||||
|
*/
|
||||||
|
class ExpiringSoon extends Widget
|
||||||
|
{
|
||||||
|
public const DAYS = 14;
|
||||||
|
|
||||||
|
protected static ?int $sort = 1;
|
||||||
|
|
||||||
|
protected string $view = 'filament.widgets.expiring-soon';
|
||||||
|
|
||||||
|
protected int|string|array $columnSpan = 'full';
|
||||||
|
|
||||||
|
public static function canView(): bool
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
return $user !== null && Expiring::for(Expiring::within(self::DAYS), $user)->isNotEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function items(): Collection
|
||||||
|
{
|
||||||
|
return Expiring::for(Expiring::within(self::DAYS), Auth::user());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@ namespace App\Http\Controllers\Api\PublicApi\V1;
|
|||||||
|
|
||||||
use App\Http\Resources\PublicApi\PublicPageResource;
|
use App\Http\Resources\PublicApi\PublicPageResource;
|
||||||
use App\Models\Page;
|
use App\Models\Page;
|
||||||
|
use App\Models\Tag;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
@@ -15,12 +17,15 @@ class PageController extends PublicController
|
|||||||
public function index(Request $request): AnonymousResourceCollection
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
{
|
{
|
||||||
$pages = Page::query()
|
$pages = Page::query()
|
||||||
->with(['category', 'featuredMedia'])
|
->with(['category', 'featuredMedia', 'tags', 'byline.avatarMedia'])
|
||||||
->unless($this->isPreview($request), fn (Builder $q) => $q->published())
|
->unless($this->isPreview($request), fn (Builder $q) => $q->published())
|
||||||
->when($request->filled('category'), fn (Builder $q) => $q
|
->when($request->filled('category'), fn (Builder $q) => $q
|
||||||
->whereHas('category', fn (Builder $c) => $c
|
->whereHas('category', fn (Builder $c) => $c
|
||||||
->where('slug->'.app()->getLocale(), $request->string('category'))))
|
->where('slug->'.app()->getLocale(), $request->string('category'))))
|
||||||
->when($request->boolean('featured'), fn (Builder $q) => $q->where('is_featured', true))
|
->when($request->boolean('featured'), fn (Builder $q) => $q->where('is_featured', true))
|
||||||
|
->when($request->filled('tag'), fn (Builder $q) => $q
|
||||||
|
->whereHas('tags', fn (Builder $t) => $t
|
||||||
|
->where('slug->'.app()->getLocale(), $request->string('tag'))))
|
||||||
->orderByDesc('published_at')
|
->orderByDesc('published_at')
|
||||||
->paginate($this->perPage($request))
|
->paginate($this->perPage($request))
|
||||||
->withQueryString();
|
->withQueryString();
|
||||||
@@ -31,11 +36,38 @@ class PageController extends PublicController
|
|||||||
public function show(Request $request, string $slug): PublicPageResource
|
public function show(Request $request, string $slug): PublicPageResource
|
||||||
{
|
{
|
||||||
$page = Page::query()
|
$page = Page::query()
|
||||||
->with(['category', 'featuredMedia'])
|
->with(['category', 'featuredMedia', 'tags', 'byline.avatarMedia'])
|
||||||
->whereSlug($slug)
|
->whereSlug($slug)
|
||||||
->unless($this->isPreview($request), fn (Builder $q) => $q->published())
|
->unless($this->isPreview($request), fn (Builder $q) => $q->published())
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
return PublicPageResource::make($page);
|
return PublicPageResource::make($page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tags used by something published, with how many posts each has in
|
||||||
|
* the request's language — for a blog to offer them as a way in.
|
||||||
|
*/
|
||||||
|
public function tags(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
$published = fn (Builder $q) => $q
|
||||||
|
->unless($this->isPreview($request), fn (Builder $q) => $q->published())
|
||||||
|
->whereNotNull("slug->{$locale}");
|
||||||
|
|
||||||
|
$tags = Tag::query()
|
||||||
|
->whereHas('pages', $published)
|
||||||
|
->withCount(['pages' => $published])
|
||||||
|
->get()
|
||||||
|
->filter(fn (Tag $tag) => filled($tag->getTranslation('slug', $locale, false)))
|
||||||
|
->sortByDesc('pages_count')
|
||||||
|
->map(fn (Tag $tag) => [
|
||||||
|
'name' => $tag->getTranslation('name', $locale),
|
||||||
|
'slug' => $tag->getTranslation('slug', $locale, false),
|
||||||
|
'count' => (int) $tag->pages_count,
|
||||||
|
])
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return response()->json(['data' => $tags]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
app/Http/Controllers/Api/PublicApi/V1/SearchController.php
Normal file
41
app/Http/Controllers/Api/PublicApi/V1/SearchController.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\PublicApi\V1;
|
||||||
|
|
||||||
|
use App\Support\Search\SiteSearch;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /public/v1/search?q=…
|
||||||
|
*
|
||||||
|
* What the site shows that matches some words, in the request's language,
|
||||||
|
* best match first.
|
||||||
|
*/
|
||||||
|
class SearchController extends PublicController
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, SiteSearch $search): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'q' => ['required', 'string', 'min:2', 'max:120'],
|
||||||
|
'type' => ['sometimes', 'array'],
|
||||||
|
'type.*' => [Rule::in(SiteSearch::TYPES)],
|
||||||
|
'limit' => ['sometimes', 'integer', 'min:1', 'max:50'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$results = $search->search(
|
||||||
|
$data['q'],
|
||||||
|
app()->getLocale(),
|
||||||
|
$data['type'] ?? SiteSearch::TYPES,
|
||||||
|
(int) ($data['limit'] ?? 20),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $results,
|
||||||
|
'meta' => ['query' => $data['q'], 'locale' => app()->getLocale(), 'count' => $results->count()],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
app/Http/Controllers/Api/V1/ApiTokenController.php
Normal file
104
app/Http/Controllers/Api/V1/ApiTokenController.php
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\ApiTokenResource;
|
||||||
|
use App\Models\ApiToken;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Permissions;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Machine tokens: made by a person, for something that is not one.
|
||||||
|
*
|
||||||
|
* Only a person signed in through the identity server manages them — a
|
||||||
|
* machine token cannot make another, or a leaked one would be a way of never
|
||||||
|
* expiring.
|
||||||
|
*/
|
||||||
|
class ApiTokenController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The caller's own tokens, newest first. A super admin may ask for
|
||||||
|
* everyone's with `all`.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$user = $this->person($request);
|
||||||
|
|
||||||
|
$tokens = ApiToken::query()
|
||||||
|
->with('user')
|
||||||
|
->unless($request->boolean('all') && $user->hasRole('super_admin'), fn ($q) => $q->whereBelongsTo($user))
|
||||||
|
->latest('id')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return ApiTokenResource::collection($tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->person($request);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:120'],
|
||||||
|
'abilities' => ['required', 'array', 'min:1'],
|
||||||
|
'abilities.*' => [
|
||||||
|
'string',
|
||||||
|
'distinct',
|
||||||
|
// Only what the person could do themselves: a token narrows,
|
||||||
|
// it never widens.
|
||||||
|
function (string $attribute, mixed $value, Closure $fail) use ($user): void {
|
||||||
|
$matches = array_filter(Permissions::all(), fn (string $permission) => $permission === $value || Str::is((string) $value, $permission));
|
||||||
|
|
||||||
|
if ($matches === [] || ! collect($matches)->contains(fn (string $permission) => $user->can($permission))) {
|
||||||
|
$fail(__('rules.tokens.ability', ['ability' => $value]));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'expires_at' => ['sometimes', 'date', 'after:now', 'before_or_equal:'.now()->addDays(ApiToken::MAX_DAYS)->toIso8601String()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
[$token, $plain] = ApiToken::issue(
|
||||||
|
$user,
|
||||||
|
$data['name'],
|
||||||
|
$data['abilities'],
|
||||||
|
isset($data['expires_at']) ? CarbonImmutable::parse($data['expires_at']) : now()->addDays(90),
|
||||||
|
);
|
||||||
|
|
||||||
|
return ApiTokenResource::make($token->load('user'))
|
||||||
|
->additional(['token' => $plain])
|
||||||
|
->response()
|
||||||
|
->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke. Kept in the list afterwards, so it is clear what stopped working.
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request, ApiToken $token): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->person($request);
|
||||||
|
|
||||||
|
abort_unless($token->user_id === $user->id || $user->hasRole('super_admin'), 403);
|
||||||
|
|
||||||
|
$token->revoke();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function person(Request $request): User
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
abort_if($user->currentAccessToken() !== null, 403, __('rules.tokens.machine'));
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
104
app/Http/Controllers/Api/V1/ApplicationController.php
Normal file
104
app/Http/Controllers/Api/V1/ApplicationController.php
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\ApplicationStatus;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\ApplicationRequest;
|
||||||
|
use App\Http\Resources\ApplicationResource;
|
||||||
|
use App\Models\Application;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inbox: what people sent from the public site.
|
||||||
|
*
|
||||||
|
* Nothing is created here — submissions arrive through the public API — and
|
||||||
|
* what can be changed is what the panel changes: the status and the private
|
||||||
|
* notes.
|
||||||
|
*/
|
||||||
|
class ApplicationController extends Controller
|
||||||
|
{
|
||||||
|
protected const WITH = ['call', 'handler'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: status, kind, call_id, search (name, e-mail or organisation), trashed.
|
||||||
|
*
|
||||||
|
* Spam stays out unless asked for by `status=spam`, as in the panel.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Application::class);
|
||||||
|
|
||||||
|
$applications = Application::query()
|
||||||
|
->with(self::WITH)
|
||||||
|
->when(
|
||||||
|
$request->filled('status'),
|
||||||
|
fn ($q) => $q->where('status', $request->string('status')),
|
||||||
|
fn ($q) => $q->notSpam(),
|
||||||
|
)
|
||||||
|
->when($request->filled('kind'), fn ($q) => $q->where('kind', $request->string('kind')))
|
||||||
|
->when($request->filled('call_id'), fn ($q) => $q->where('call_id', $request->integer('call_id')))
|
||||||
|
->when($request->filled('search'), function ($q) use ($request): void {
|
||||||
|
$term = '%'.$request->string('search').'%';
|
||||||
|
|
||||||
|
$q->where(fn ($q) => $q
|
||||||
|
->where('name', 'like', $term)
|
||||||
|
->orWhere('email', 'like', $term)
|
||||||
|
->orWhere('organisation', 'like', $term));
|
||||||
|
})
|
||||||
|
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
|
||||||
|
->latest()
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return ApplicationResource::collection($applications);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Application $application): ApplicationResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $application);
|
||||||
|
|
||||||
|
return ApplicationResource::make($application->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(ApplicationRequest $request, Application $application): ApplicationResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $application);
|
||||||
|
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
// Notes are outside the model's fillable list so the public form can
|
||||||
|
// never reach them; this is the one other place that writes them.
|
||||||
|
if (array_key_exists('notes', $data)) {
|
||||||
|
$application->forceFill(['notes' => $data['notes']])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['status'])) {
|
||||||
|
$application->markAs(ApplicationStatus::from($data['status']), $request->user());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApplicationResource::make($application->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Application $application): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $application);
|
||||||
|
|
||||||
|
$application->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(Application $application): ApplicationResource
|
||||||
|
{
|
||||||
|
$this->authorize('restore', $application);
|
||||||
|
|
||||||
|
$application->restore();
|
||||||
|
|
||||||
|
return ApplicationResource::make($application->load(self::WITH));
|
||||||
|
}
|
||||||
|
}
|
||||||
98
app/Http/Controllers/Api/V1/ApplicationReplyController.php
Normal file
98
app/Http/Controllers/Api/V1/ApplicationReplyController.php
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\ApplicationReply;
|
||||||
|
use App\Models\ReplyTemplate;
|
||||||
|
use App\Support\Inbox\Replier;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answering the inbox through the API: what was already said, the templates
|
||||||
|
* to start from, and sending an answer — the panel's "Reply".
|
||||||
|
*/
|
||||||
|
class ApplicationReplyController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Application $application): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('view', $application);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $application->replies()->get()->map(fn (ApplicationReply $reply) => $this->present($reply))->values(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `subject` and `body`, or a `template_id` to fill them from — in the
|
||||||
|
* language the person wrote in. Sent when this installation can send
|
||||||
|
* mail; otherwise recorded, with a `mailto` link to send it by hand.
|
||||||
|
*/
|
||||||
|
public function store(Request $request, Application $application): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('update', $application);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'template_id' => ['sometimes', 'nullable', 'integer', 'exists:reply_templates,id'],
|
||||||
|
'subject' => ['required_without:template_id', 'nullable', 'string', 'max:255'],
|
||||||
|
'body' => ['required_without:template_id', 'nullable', 'string', 'max:20000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$text = ['subject' => $data['subject'] ?? null, 'body' => $data['body'] ?? null];
|
||||||
|
|
||||||
|
if (filled($data['template_id'] ?? null)) {
|
||||||
|
$rendered = ReplyTemplate::query()->findOrFail($data['template_id'])->renderFor($application, $request->user());
|
||||||
|
$text = ['subject' => $text['subject'] ?: $rendered['subject'], 'body' => $text['body'] ?: $rendered['body']];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blank($text['subject']) || blank($text['body'])) {
|
||||||
|
throw ValidationException::withMessages(['template_id' => [__('panel.replies.empty_template')]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = Replier::reply($application, $text['subject'], $text['body'], $request->user());
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => [
|
||||||
|
...$this->present($reply),
|
||||||
|
'mailto' => $reply->channel === ApplicationReply::MAILTO
|
||||||
|
? Replier::mailto($application, $text['subject'], $text['body'])
|
||||||
|
: null,
|
||||||
|
],
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function templates(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', ReplyTemplate::class);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => ReplyTemplate::query()->orderBy('position')->orderBy('name')->get()->map(fn (ReplyTemplate $template) => [
|
||||||
|
'id' => $template->id,
|
||||||
|
'name' => $template->name,
|
||||||
|
'kind' => $template->kind,
|
||||||
|
'subject' => (object) $template->getTranslations('subject'),
|
||||||
|
'body' => (object) $template->getTranslations('body'),
|
||||||
|
])->values(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function present(ApplicationReply $reply): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $reply->id,
|
||||||
|
'subject' => $reply->subject,
|
||||||
|
'body' => $reply->body,
|
||||||
|
'channel' => $reply->channel,
|
||||||
|
'author' => ['id' => $reply->user_id, 'name' => $reply->user_name],
|
||||||
|
'created_at' => $reply->created_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/Http/Controllers/Api/V1/AuditController.php
Normal file
53
app/Http/Controllers/Api/V1/AuditController.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\AuditLogResource;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /v1/audit — who did what, when, and how they came in.
|
||||||
|
*/
|
||||||
|
class AuditController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Newest first. Filters: user_id, action (exact, or a prefix ending in
|
||||||
|
* `.`, e.g. `pages.`), subject_type, subject_id, via, from, to.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('audit.view_any');
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'via' => ['sometimes', Rule::in(['panel', 'api', 'token', 'web', 'console'])],
|
||||||
|
'from' => ['sometimes', 'date'],
|
||||||
|
'to' => ['sometimes', 'date'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$entries = AuditLog::query()
|
||||||
|
->when($request->filled('user_id'), fn ($q) => $q->where('user_id', $request->integer('user_id')))
|
||||||
|
->when($request->filled('action'), function ($q) use ($request): void {
|
||||||
|
$action = (string) $request->string('action');
|
||||||
|
|
||||||
|
str_ends_with($action, '.')
|
||||||
|
? $q->where('action', 'like', $action.'%')
|
||||||
|
: $q->where('action', $action);
|
||||||
|
})
|
||||||
|
->when($request->filled('subject_type'), fn ($q) => $q->where('subject_type', $request->string('subject_type')))
|
||||||
|
->when($request->filled('subject_id'), fn ($q) => $q->where('subject_id', (string) $request->string('subject_id')))
|
||||||
|
->when($request->filled('via'), fn ($q) => $q->where('via', $request->string('via')))
|
||||||
|
->when($request->filled('from'), fn ($q) => $q->where('created_at', '>=', $request->date('from')))
|
||||||
|
->when($request->filled('to'), fn ($q) => $q->where('created_at', '<=', $request->date('to')))
|
||||||
|
->latest('id')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 50), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return AuditLogResource::collection($entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
app/Http/Controllers/Api/V1/BatchController.php
Normal file
99
app/Http/Controllers/Api/V1/BatchController.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Support\AdminContent;
|
||||||
|
use App\Support\Batch\BatchRunner;
|
||||||
|
use App\Support\Batch\Importer;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Several calls in one: a batch of any admin operations, and importing rows
|
||||||
|
* of one kind of record from JSON or CSV.
|
||||||
|
*/
|
||||||
|
class BatchController extends Controller
|
||||||
|
{
|
||||||
|
public function batch(Request $request, BatchRunner $runner): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'operations' => ['required', 'array', 'min:1', 'max:'.BatchRunner::MAX_OPERATIONS],
|
||||||
|
'operations.*.method' => ['required', Rule::in(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'get', 'post', 'put', 'patch', 'delete'])],
|
||||||
|
'operations.*.path' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:500',
|
||||||
|
function (string $attribute, mixed $value, Closure $fail): void {
|
||||||
|
if (! is_string($value) || ! BatchRunner::allows($value)) {
|
||||||
|
$fail(__('rules.batch.path'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'operations.*.body' => ['sometimes', 'nullable', 'array'],
|
||||||
|
'atomic' => ['sometimes', 'boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$outcome = $runner->run($request, $data['operations'], atomic: $request->boolean('atomic'));
|
||||||
|
|
||||||
|
return $this->answer($outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rows as JSON (`rows`) or a CSV file (`file`). `match` names the field
|
||||||
|
* that finds an existing record to update; `dry_run` validates everything
|
||||||
|
* and keeps nothing.
|
||||||
|
*/
|
||||||
|
public function import(Request $request, string $resource, BatchRunner $runner, Importer $importer): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless(array_key_exists($resource, AdminContent::WITH_HISTORY), 404);
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'rows' => ['required_without:file', 'array'],
|
||||||
|
'rows.*' => ['array'],
|
||||||
|
'file' => ['required_without:rows', 'file', 'mimes:csv,txt', 'max:2048'],
|
||||||
|
'match' => ['sometimes', 'nullable', 'string', Rule::in(['slug', 'name', 'full_name'])],
|
||||||
|
'atomic' => ['sometimes', 'boolean'],
|
||||||
|
'dry_run' => ['sometimes', 'boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rows = $request->hasFile('file')
|
||||||
|
? $importer->rowsFromCsv($request->file('file'))
|
||||||
|
: $request->input('rows');
|
||||||
|
|
||||||
|
$operations = $importer->operations($resource, $rows, $request->input('match'));
|
||||||
|
|
||||||
|
// A dry run is all-or-nothing by nature: it keeps nothing either way.
|
||||||
|
$outcome = $runner->run(
|
||||||
|
$request,
|
||||||
|
$operations,
|
||||||
|
atomic: $request->boolean('atomic', true) || $request->boolean('dry_run'),
|
||||||
|
rollback: $request->boolean('dry_run'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->answer($outcome, ['dry_run' => $request->boolean('dry_run')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{results: array<int, array<string, mixed>>, committed: bool} $outcome
|
||||||
|
* @param array<string, mixed> $meta
|
||||||
|
*/
|
||||||
|
protected function answer(array $outcome, array $meta = []): JsonResponse
|
||||||
|
{
|
||||||
|
$failed = collect($outcome['results'])->where('status', '>=', 400)->count();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $outcome['results'],
|
||||||
|
'meta' => [
|
||||||
|
...$meta,
|
||||||
|
'committed' => $outcome['committed'],
|
||||||
|
'succeeded' => count($outcome['results']) - $failed,
|
||||||
|
'failed' => $failed,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
app/Http/Controllers/Api/V1/CallController.php
Normal file
95
app/Http/Controllers/Api/V1/CallController.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\CallRequest;
|
||||||
|
use App\Http\Resources\CallResource;
|
||||||
|
use App\Models\Call;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open calls for contributors. Deleted for good, like the panel does.
|
||||||
|
*/
|
||||||
|
class CallController extends Controller
|
||||||
|
{
|
||||||
|
protected const WITH = ['technologies'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: status, project_id, search (title in the active language).
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Call::class);
|
||||||
|
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
|
||||||
|
$calls = Call::query()
|
||||||
|
->with(self::WITH)
|
||||||
|
->withCount('applications')
|
||||||
|
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
|
||||||
|
->when($request->filled('project_id'), fn ($q) => $q->where('project_id', $request->integer('project_id')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where("title->{$locale}", 'like', '%'.$request->string('search').'%'))
|
||||||
|
->latest('id')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return CallResource::collection($calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(CallRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Call::class);
|
||||||
|
|
||||||
|
return CallResource::make($this->save(new Call, $request))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Call $call): CallResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $call);
|
||||||
|
|
||||||
|
return CallResource::make($call->load(self::WITH)->loadCount('applications'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(CallRequest $request, Call $call): CallResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $call);
|
||||||
|
|
||||||
|
return CallResource::make($this->save($call, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Call $call): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $call);
|
||||||
|
|
||||||
|
$call->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function save(Call $call, CallRequest $request): Call
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($call, $data): void {
|
||||||
|
$call->fill(TranslatablePayload::merge(
|
||||||
|
$call,
|
||||||
|
collect($data)->except(['technology_ids'])->all(),
|
||||||
|
$call->translatable,
|
||||||
|
));
|
||||||
|
$call->save();
|
||||||
|
|
||||||
|
if (array_key_exists('technology_ids', $data)) {
|
||||||
|
$call->technologies()->sync($data['technology_ids']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $call->load(self::WITH)->loadCount('applications');
|
||||||
|
}
|
||||||
|
}
|
||||||
90
app/Http/Controllers/Api/V1/ClientController.php
Normal file
90
app/Http/Controllers/Api/V1/ClientController.php
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\ClientRequest;
|
||||||
|
use App\Http\Resources\ClientResource;
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The organisations the work was for, as the panel edits them.
|
||||||
|
*/
|
||||||
|
class ClientController extends Controller
|
||||||
|
{
|
||||||
|
protected const WITH = ['logoMedia'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: is_public, search (name), trashed.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Client::class);
|
||||||
|
|
||||||
|
$clients = Client::query()
|
||||||
|
->with(self::WITH)
|
||||||
|
->withCount('projects')
|
||||||
|
->when($request->has('is_public'), fn ($q) => $q->where('is_public', $request->boolean('is_public')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where('name', 'like', '%'.$request->string('search').'%'))
|
||||||
|
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
|
||||||
|
->orderBy('position')
|
||||||
|
->orderBy('name')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return ClientResource::collection($clients);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(ClientRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Client::class);
|
||||||
|
|
||||||
|
return ClientResource::make($this->save(new Client, $request))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Client $client): ClientResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $client);
|
||||||
|
|
||||||
|
return ClientResource::make($client->load(self::WITH)->loadCount('projects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(ClientRequest $request, Client $client): ClientResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $client);
|
||||||
|
|
||||||
|
return ClientResource::make($this->save($client, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Client $client): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $client);
|
||||||
|
|
||||||
|
$client->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(Client $client): ClientResource
|
||||||
|
{
|
||||||
|
$this->authorize('restore', $client);
|
||||||
|
|
||||||
|
$client->restore();
|
||||||
|
|
||||||
|
return ClientResource::make($client->load(self::WITH)->loadCount('projects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function save(Client $client, ClientRequest $request): Client
|
||||||
|
{
|
||||||
|
$client->fill(TranslatablePayload::merge($client, $request->validated(), $client->translatable));
|
||||||
|
$client->save();
|
||||||
|
|
||||||
|
return $client->load(self::WITH)->loadCount('projects');
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Http/Controllers/Api/V1/Concerns/ResolvesContent.php
Normal file
29
app/Http/Controllers/Api/V1/Concerns/ResolvesContent.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Concerns;
|
||||||
|
|
||||||
|
use App\Support\AdminContent;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For the endpoints shared by several kinds of content —
|
||||||
|
* `/pages/{page}/revisions`, `/projects/{project}/revisions` — whose route
|
||||||
|
* says which kind through its `content` default.
|
||||||
|
*/
|
||||||
|
trait ResolvesContent
|
||||||
|
{
|
||||||
|
protected function content(Request $request): string
|
||||||
|
{
|
||||||
|
return (string) $request->route('content');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function record(Request $request): Model
|
||||||
|
{
|
||||||
|
$content = $this->content($request);
|
||||||
|
|
||||||
|
return AdminContent::find($content, $request->route(AdminContent::parameter($content)));
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Http/Controllers/Api/V1/DuplicateController.php
Normal file
37
app/Http/Controllers/Api/V1/DuplicateController.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\V1\Concerns\ResolvesContent;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Support\ContentTypes;
|
||||||
|
use App\Support\Duplicator;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A draft copy of a page, project, service or call — the panel's "Duplicate".
|
||||||
|
*/
|
||||||
|
class DuplicateController extends Controller
|
||||||
|
{
|
||||||
|
use ResolvesContent;
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$record = $this->record($request);
|
||||||
|
|
||||||
|
$this->authorize('view', $record);
|
||||||
|
$this->authorize('create', $record::class);
|
||||||
|
|
||||||
|
$copy = Duplicator::duplicate($record);
|
||||||
|
|
||||||
|
return response()->json(['data' => [
|
||||||
|
'type' => $this->content($request),
|
||||||
|
'id' => $copy->getKey(),
|
||||||
|
'title' => ContentTypes::title($copy),
|
||||||
|
'slug' => (object) $copy->getTranslations('slug'),
|
||||||
|
]], 201);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/Http/Controllers/Api/V1/HealthController.php
Normal file
81
app/Http/Controllers/Api/V1/HealthController.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Support\Health\Finding;
|
||||||
|
use App\Support\Health\HealthReport;
|
||||||
|
use App\Support\PendingTranslations;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is wrong with the site, and what is left to translate — the panel's
|
||||||
|
* health screen, for a client that cannot open it.
|
||||||
|
*/
|
||||||
|
class HealthController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Every check the caller may see, problems first. A check behind a
|
||||||
|
* permission they lack is left out, as on the panel.
|
||||||
|
*/
|
||||||
|
public function index(Request $request, HealthReport $report): JsonResponse
|
||||||
|
{
|
||||||
|
// Who may open the panel's screen; each check guards itself further.
|
||||||
|
$this->authorize('panel.access');
|
||||||
|
|
||||||
|
$findings = $report->runFor($request->user());
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $findings->map(fn (Finding $finding) => [
|
||||||
|
'key' => $finding->key,
|
||||||
|
'severity' => $finding->severity->value,
|
||||||
|
'count' => $finding->count,
|
||||||
|
'title' => $finding->title(),
|
||||||
|
'description' => $finding->description(),
|
||||||
|
'samples' => $finding->samples,
|
||||||
|
'panel_url' => $finding->url,
|
||||||
|
])->values(),
|
||||||
|
'meta' => [
|
||||||
|
'problems' => $findings->filter(fn (Finding $f) => $f->severity->isProblem())->count(),
|
||||||
|
'checked_at' => now()->toIso8601String(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records with a language still to write (`missing`) or left behind by the
|
||||||
|
* one it was translated from (`stale`, with since when).
|
||||||
|
*
|
||||||
|
* Filters: locale (only what that language lacks), type, published (only
|
||||||
|
* what is on the site — open, for calls).
|
||||||
|
*/
|
||||||
|
public function translations(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$locales = array_keys((array) config('locales.supported'));
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'locale' => ['sometimes', Rule::in($locales)],
|
||||||
|
'type' => ['sometimes', Rule::in(['pages', 'projects', 'services', 'calls'])],
|
||||||
|
'published' => ['sometimes', 'boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$items = PendingTranslations::find(
|
||||||
|
$request->user(),
|
||||||
|
$request->input('locale'),
|
||||||
|
$request->input('type'),
|
||||||
|
$request->boolean('published'),
|
||||||
|
)->map(fn (array $item) => [
|
||||||
|
'type' => $item['type'],
|
||||||
|
'id' => $item['id'],
|
||||||
|
'title' => $item['title'],
|
||||||
|
'missing' => $item['missing'],
|
||||||
|
'stale' => (object) collect($item['stale'])->map(fn ($since) => $since->toIso8601String())->all(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['data' => $items->values()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Http/Controllers/Api/V1/MaintenanceController.php
Normal file
68
app/Http/Controllers/Api/V1/MaintenanceController.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Support\Maintenance;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public site's maintenance mode — the panel's maintenance screen, as two
|
||||||
|
* calls. Only the public site goes down; the panel and this API keep working.
|
||||||
|
*/
|
||||||
|
class MaintenanceController extends Controller
|
||||||
|
{
|
||||||
|
public function show(): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('settings.view');
|
||||||
|
|
||||||
|
return $this->state();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `enabled` switches it; `message` (language → text) and `until` are kept
|
||||||
|
* either way, so the wording can be prepared before the site goes down.
|
||||||
|
*/
|
||||||
|
public function update(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('settings.maintenance');
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'enabled' => ['required', 'boolean'],
|
||||||
|
'message' => ['sometimes', 'nullable', 'array'],
|
||||||
|
'message.*' => ['nullable', 'string', 'max:500'],
|
||||||
|
'until' => ['sometimes', 'nullable', 'date', 'after:now'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$message = array_key_exists('message', $data)
|
||||||
|
? array_filter((array) $data['message'], fn ($text) => filled($text))
|
||||||
|
: Maintenance::messages();
|
||||||
|
|
||||||
|
if ($data['enabled']) {
|
||||||
|
Maintenance::enable(
|
||||||
|
message: $message,
|
||||||
|
until: filled($data['until'] ?? null) ? CarbonImmutable::parse($data['until']) : null,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Switching it off must not take the prepared wording with it.
|
||||||
|
Setting::set(Maintenance::KEY, [...Maintenance::state(), 'enabled' => false, 'until' => null, 'message' => $message]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->state();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function state(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(['data' => [
|
||||||
|
'enabled' => Maintenance::isEnabled(),
|
||||||
|
'message' => (object) Maintenance::messages(),
|
||||||
|
'until' => Maintenance::until()?->toIso8601String(),
|
||||||
|
'started_at' => Maintenance::startedAt()?->toIso8601String(),
|
||||||
|
]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
187
app/Http/Controllers/Api/V1/MediaController.php
Normal file
187
app/Http/Controllers/Api/V1/MediaController.php
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\MediaRequest;
|
||||||
|
use App\Http\Resources\MediaResource;
|
||||||
|
use App\Jobs\ProcessMedia;
|
||||||
|
use App\Models\Media;
|
||||||
|
use App\Services\Media\RemoteImage;
|
||||||
|
use enshrined\svgSanitize\Sanitizer;
|
||||||
|
use finfo;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The media library: every image on the media disk, and uploading new ones.
|
||||||
|
*
|
||||||
|
* `store` stores an image and answers with the path a record points at — what the
|
||||||
|
* panel's upload field does on a form, without the form. It exists for the MCP
|
||||||
|
* server, which otherwise could create a page but never give it a picture.
|
||||||
|
*
|
||||||
|
* The file lands where the panel puts a page's images and goes through
|
||||||
|
* `Media::for()`, so it is processed, served and eventually pruned exactly
|
||||||
|
* like one uploaded there.
|
||||||
|
*/
|
||||||
|
class MediaController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Extension by detected type. Chosen from the bytes, never from the name
|
||||||
|
* the client sent.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
protected const EXTENSIONS = [
|
||||||
|
'image/jpeg' => 'jpg',
|
||||||
|
'image/png' => 'png',
|
||||||
|
'image/webp' => 'webp',
|
||||||
|
'image/gif' => 'gif',
|
||||||
|
'image/avif' => 'avif',
|
||||||
|
'image/svg+xml' => 'svg',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: for (the kind of record it was uploaded for), mime, search
|
||||||
|
* (path), pending, failed, orphans (what `content:prune --media` would
|
||||||
|
* take away).
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Media::class);
|
||||||
|
|
||||||
|
// Worked out once for the page rather than once per file: it reads
|
||||||
|
// every record that can point at an image.
|
||||||
|
$referenced = array_flip(Media::referencedPaths());
|
||||||
|
|
||||||
|
$directory = config('media.upload.for.'.$request->string('for'));
|
||||||
|
|
||||||
|
$media = Media::query()
|
||||||
|
->when(is_string($directory), fn ($q) => $q->where('path', 'like', $directory.'/%'))
|
||||||
|
->when($request->filled('mime'), fn ($q) => $q->where('mime', $request->string('mime')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where('path', 'like', '%'.$request->string('search').'%'))
|
||||||
|
->when($request->boolean('pending'), fn ($q) => $q->pending())
|
||||||
|
->when($request->boolean('failed'), fn ($q) => $q->whereNotNull('error'))
|
||||||
|
->when($request->boolean('orphans'), fn ($q) => $q->whereNotIn('path', array_keys($referenced)))
|
||||||
|
->latest('id')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
$media->getCollection()->each(fn (Media $item) => $item->setAttribute('referenced', isset($referenced[$item->path])));
|
||||||
|
|
||||||
|
return MediaResource::collection($media);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Media $media): MediaResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $media);
|
||||||
|
|
||||||
|
return MediaResource::make($media->setAttribute('referenced', $media->isReferenced()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only a file nothing points at: deleting one in use would put a broken
|
||||||
|
* image on the public site. Its derived versions go with it.
|
||||||
|
*/
|
||||||
|
public function destroy(Media $media): JsonResponse
|
||||||
|
{
|
||||||
|
// The permission first, so whether a file is in use is only told to
|
||||||
|
// someone who could have deleted it.
|
||||||
|
$this->authorize('media.delete');
|
||||||
|
|
||||||
|
abort_if($media->isReferenced(), 409, __('rules.media.in_use'));
|
||||||
|
|
||||||
|
$media->deleteDerived();
|
||||||
|
Storage::disk($media->disk)->delete($media->path);
|
||||||
|
$media->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the smaller versions and the placeholder again, on the queue.
|
||||||
|
*/
|
||||||
|
public function reprocess(Media $media): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('view', $media);
|
||||||
|
|
||||||
|
ProcessMedia::dispatch($media);
|
||||||
|
|
||||||
|
return MediaResource::make($media)->response()->setStatusCode(202);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(MediaRequest $request, RemoteImage $remote): JsonResponse
|
||||||
|
{
|
||||||
|
$for = (string) $request->input('for', 'pages');
|
||||||
|
|
||||||
|
$this->authorize('upload', [Media::class, $for]);
|
||||||
|
|
||||||
|
$field = $request->hasFile('file') ? 'file' : 'url';
|
||||||
|
|
||||||
|
$contents = $field === 'file'
|
||||||
|
? (string) $request->file('file')->get()
|
||||||
|
: $remote->fetch((string) $request->string('url'));
|
||||||
|
|
||||||
|
$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($contents) ?: null;
|
||||||
|
|
||||||
|
if (! in_array($mime, (array) config('media.upload.mimes'), true)) {
|
||||||
|
$this->refuse($field);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mime === 'image/svg+xml') {
|
||||||
|
$contents = $this->sanitize($contents) ?? $this->refuse($field);
|
||||||
|
// A vector has no pixel size; the processor copies it through.
|
||||||
|
$size = [null, null];
|
||||||
|
} else {
|
||||||
|
// The type is read from the magic bytes; this catches a file that
|
||||||
|
// has them but does not decode, which nobody could render either.
|
||||||
|
$size = @getimagesizefromstring($contents) ?: $this->refuse($field);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = config("media.upload.for.{$for}").'/'.Str::random(40).'.'.self::EXTENSIONS[$mime];
|
||||||
|
|
||||||
|
Storage::disk((string) config('media.disk'))->put($path, $contents);
|
||||||
|
|
||||||
|
$media = Media::for($path);
|
||||||
|
|
||||||
|
// What is already known, so the answer carries dimensions straight
|
||||||
|
// away rather than after the queue has run. The processor writes the
|
||||||
|
// same values, and the rest, when it does.
|
||||||
|
if (! $media->isProcessed()) {
|
||||||
|
$media->forceFill([
|
||||||
|
'mime' => $mime,
|
||||||
|
'size' => strlen($contents),
|
||||||
|
'width' => $size[0],
|
||||||
|
'height' => $size[1],
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaResource::make($media->fresh())->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An SVG with what could run taken out: scripts, event handlers, and
|
||||||
|
* references to anything outside the file. Null when what is left is not
|
||||||
|
* an SVG at all.
|
||||||
|
*/
|
||||||
|
protected function sanitize(string $svg): ?string
|
||||||
|
{
|
||||||
|
$sanitizer = new Sanitizer;
|
||||||
|
$sanitizer->removeRemoteReferences(true);
|
||||||
|
|
||||||
|
$clean = $sanitizer->sanitize($svg);
|
||||||
|
|
||||||
|
return is_string($clean) && str_contains($clean, '<svg') ? $clean : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function refuse(string $field): never
|
||||||
|
{
|
||||||
|
throw ValidationException::withMessages([$field => [__('rules.media.not_an_image')]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
103
app/Http/Controllers/Api/V1/MemberController.php
Normal file
103
app/Http/Controllers/Api/V1/MemberController.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\MemberRequest;
|
||||||
|
use App\Http\Resources\MemberResource;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The people in the collective, as the panel edits them. MemberPolicy lets
|
||||||
|
* someone edit their own profile without `members.update`, as in the panel.
|
||||||
|
*/
|
||||||
|
class MemberController extends Controller
|
||||||
|
{
|
||||||
|
protected const WITH = ['avatarMedia', 'technologies', 'projects'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: search (name), is_public, trashed.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Member::class);
|
||||||
|
|
||||||
|
$members = Member::query()
|
||||||
|
->with(self::WITH)
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where('name', 'like', '%'.$request->string('search').'%'))
|
||||||
|
->when($request->has('is_public'), fn ($q) => $q->where('is_public', $request->boolean('is_public')))
|
||||||
|
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
|
||||||
|
->orderBy('position')
|
||||||
|
->orderBy('name')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return MemberResource::collection($members);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(MemberRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Member::class);
|
||||||
|
|
||||||
|
return MemberResource::make($this->save(new Member, $request))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Member $member): MemberResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $member);
|
||||||
|
|
||||||
|
return MemberResource::make($member->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(MemberRequest $request, Member $member): MemberResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $member);
|
||||||
|
|
||||||
|
return MemberResource::make($this->save($member, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Member $member): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $member);
|
||||||
|
|
||||||
|
$member->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(Member $member): MemberResource
|
||||||
|
{
|
||||||
|
$this->authorize('restore', $member);
|
||||||
|
|
||||||
|
$member->restore();
|
||||||
|
|
||||||
|
return MemberResource::make($member->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function save(Member $member, MemberRequest $request): Member
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($member, $data): void {
|
||||||
|
$member->fill(TranslatablePayload::merge(
|
||||||
|
$member,
|
||||||
|
collect($data)->except('technology_ids')->all(),
|
||||||
|
$member->translatable,
|
||||||
|
));
|
||||||
|
$member->save();
|
||||||
|
|
||||||
|
if (array_key_exists('technology_ids', $data)) {
|
||||||
|
$member->technologies()->sync($data['technology_ids']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $member->load(self::WITH);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
app/Http/Controllers/Api/V1/MissingUrlController.php
Normal file
95
app/Http/Controllers/Api/V1/MissingUrlController.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\MissingUrlStatus;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\MissingUrlRedirectRequest;
|
||||||
|
use App\Http\Resources\MissingUrlResource;
|
||||||
|
use App\Http\Resources\RedirectResource;
|
||||||
|
use App\Models\MissingUrl;
|
||||||
|
use App\Models\Redirect;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Addresses the public site was asked for and could not find, reported by the
|
||||||
|
* frontend. What can be done with one is what the panel does: ignore it, or
|
||||||
|
* give it a destination.
|
||||||
|
*/
|
||||||
|
class MissingUrlController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Filters: status (pending by default), locale, search (path).
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', MissingUrl::class);
|
||||||
|
|
||||||
|
$missing = MissingUrl::query()
|
||||||
|
->where('status', $request->string('status', MissingUrlStatus::Pending->value))
|
||||||
|
->when($request->filled('locale'), fn ($q) => $q->where('locale', $request->string('locale')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where('path', 'like', '%'.$request->string('search').'%'))
|
||||||
|
->orderByDesc('hits')
|
||||||
|
->orderByDesc('last_seen_at')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return MissingUrlResource::collection($missing);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(MissingUrl $missingUrl): MissingUrlResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $missingUrl);
|
||||||
|
|
||||||
|
return MissingUrlResource::make($missingUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only between pending and ignored: `resolved` is what writing a redirect
|
||||||
|
* makes it, and claiming it without one would be a lie the next sighting
|
||||||
|
* undoes anyway.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, MissingUrl $missingUrl): MissingUrlResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $missingUrl);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'status' => ['required', Rule::in([MissingUrlStatus::Pending->value, MissingUrlStatus::Ignored->value])],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$missingUrl->update($data);
|
||||||
|
|
||||||
|
return MissingUrlResource::make($missingUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(MissingUrl $missingUrl): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $missingUrl);
|
||||||
|
|
||||||
|
$missingUrl->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give the address a destination. The report is marked resolved by the
|
||||||
|
* redirect being created, as from anywhere else.
|
||||||
|
*/
|
||||||
|
public function redirect(MissingUrlRedirectRequest $request, MissingUrl $missingUrl): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('redirect', $missingUrl);
|
||||||
|
|
||||||
|
$redirect = Redirect::create([
|
||||||
|
'from' => $missingUrl->path,
|
||||||
|
'is_enabled' => true,
|
||||||
|
...$request->validated(),
|
||||||
|
])->refresh();
|
||||||
|
|
||||||
|
return RedirectResource::make($redirect)->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
|||||||
|
|
||||||
class PageController extends Controller
|
class PageController extends Controller
|
||||||
{
|
{
|
||||||
|
protected const WITH = ['category', 'author', 'featuredMedia', 'tags'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/pages
|
* GET /api/v1/pages
|
||||||
*
|
*
|
||||||
@@ -30,7 +32,7 @@ class PageController extends Controller
|
|||||||
$locale = app()->getLocale();
|
$locale = app()->getLocale();
|
||||||
|
|
||||||
$pages = Page::query()
|
$pages = Page::query()
|
||||||
->with(['category', 'author'])
|
->with(self::WITH)
|
||||||
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
|
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
|
||||||
->when($request->filled('category_id'), fn ($q) => $q->where('category_id', $request->integer('category_id')))
|
->when($request->filled('category_id'), fn ($q) => $q->where('category_id', $request->integer('category_id')))
|
||||||
->when($request->has('is_featured'), fn ($q) => $q->where('is_featured', $request->boolean('is_featured')))
|
->when($request->has('is_featured'), fn ($q) => $q->where('is_featured', $request->boolean('is_featured')))
|
||||||
@@ -57,7 +59,7 @@ class PageController extends Controller
|
|||||||
$this->authorize('create', Page::class);
|
$this->authorize('create', Page::class);
|
||||||
|
|
||||||
$page = new Page;
|
$page = new Page;
|
||||||
$page->fill(TranslatablePayload::merge($page, $request->validated(), $page->translatable));
|
$page->fill(TranslatablePayload::merge($page, collect($request->validated())->except('tag_ids')->all(), $page->translatable));
|
||||||
$page->author_id ??= $request->user()->id;
|
$page->author_id ??= $request->user()->id;
|
||||||
|
|
||||||
// A page created as published with no explicit date goes live now.
|
// A page created as published with no explicit date goes live now.
|
||||||
@@ -66,8 +68,9 @@ class PageController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$page->save();
|
$page->save();
|
||||||
|
$this->syncTags($page, $request);
|
||||||
|
|
||||||
return PageResource::make($page->load(['category', 'author']))
|
return PageResource::make($page->load(self::WITH))
|
||||||
->response()
|
->response()
|
||||||
->setStatusCode(201);
|
->setStatusCode(201);
|
||||||
}
|
}
|
||||||
@@ -76,22 +79,23 @@ class PageController extends Controller
|
|||||||
{
|
{
|
||||||
$this->authorize('view', $page);
|
$this->authorize('view', $page);
|
||||||
|
|
||||||
return PageResource::make($page->load(['category', 'author']));
|
return PageResource::make($page->load(self::WITH));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(PageRequest $request, Page $page): PageResource
|
public function update(PageRequest $request, Page $page): PageResource
|
||||||
{
|
{
|
||||||
$this->authorize('update', $page);
|
$this->authorize('update', $page);
|
||||||
|
|
||||||
$page->fill(TranslatablePayload::merge($page, $request->validated(), $page->translatable));
|
$page->fill(TranslatablePayload::merge($page, collect($request->validated())->except('tag_ids')->all(), $page->translatable));
|
||||||
|
|
||||||
if ($page->status === PageStatus::Published && $page->published_at === null) {
|
if ($page->status === PageStatus::Published && $page->published_at === null) {
|
||||||
$page->published_at = now();
|
$page->published_at = now();
|
||||||
}
|
}
|
||||||
|
|
||||||
$page->save();
|
$page->save();
|
||||||
|
$this->syncTags($page, $request);
|
||||||
|
|
||||||
return PageResource::make($page->load(['category', 'author']));
|
return PageResource::make($page->load(self::WITH));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Page $page): JsonResponse
|
public function destroy(Page $page): JsonResponse
|
||||||
@@ -109,7 +113,7 @@ class PageController extends Controller
|
|||||||
|
|
||||||
$page->restore();
|
$page->restore();
|
||||||
|
|
||||||
return PageResource::make($page->load(['category', 'author']));
|
return PageResource::make($page->load(self::WITH));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,4 +139,11 @@ class PageController extends Controller
|
|||||||
|
|
||||||
$query->orderBy(in_array($column, $sortable, true) ? $column : 'created_at', $direction);
|
$query->orderBy(in_array($column, $sortable, true) ? $column : 'created_at', $direction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function syncTags(Page $page, PageRequest $request): void
|
||||||
|
{
|
||||||
|
if ($request->has('tag_ids')) {
|
||||||
|
$page->tags()->sync($request->validated('tag_ids') ?? []);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
app/Http/Controllers/Api/V1/PreviewController.php
Normal file
43
app/Http/Controllers/Api/V1/PreviewController.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\V1\Concerns\ResolvesContent;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Support\AdminContent;
|
||||||
|
use App\Support\PreviewLink;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Links to see a draft on the real site, one per language it has an address
|
||||||
|
* in — the panel's preview button. Each opens that one record only, and stops
|
||||||
|
* working after `PreviewLink::HOURS`.
|
||||||
|
*/
|
||||||
|
class PreviewController extends Controller
|
||||||
|
{
|
||||||
|
use ResolvesContent;
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$record = $this->record($request);
|
||||||
|
|
||||||
|
$this->authorize('view', $record);
|
||||||
|
|
||||||
|
$type = AdminContent::PREVIEWABLE[$this->content($request)];
|
||||||
|
|
||||||
|
$links = collect(array_keys((array) config('locales.supported')))
|
||||||
|
->mapWithKeys(fn (string $locale) => [$locale => PreviewLink::for($record, $type, $locale)])
|
||||||
|
->filter()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => [
|
||||||
|
'links' => (object) $links,
|
||||||
|
'expires_at' => now()->addHours(PreviewLink::HOURS)->toIso8601String(),
|
||||||
|
],
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
}
|
||||||
122
app/Http/Controllers/Api/V1/ProjectController.php
Normal file
122
app/Http/Controllers/Api/V1/ProjectController.php
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\ProjectRequest;
|
||||||
|
use App\Http\Resources\ProjectResource;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projects, as the panel edits them — so the MCP server can load the
|
||||||
|
* collective's work without anyone copying it into a form by hand.
|
||||||
|
*/
|
||||||
|
class ProjectController extends Controller
|
||||||
|
{
|
||||||
|
/** Relations every answer carries, so a client can PATCH back what it read. */
|
||||||
|
protected const WITH = ['coverMedia', 'technologies', 'services', 'members'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: status, search (title in the active language), trashed.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Project::class);
|
||||||
|
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
|
||||||
|
$projects = Project::query()
|
||||||
|
->with(self::WITH)
|
||||||
|
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where("title->{$locale}", 'like', '%'.$request->string('search').'%'))
|
||||||
|
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
|
||||||
|
->orderBy('position')
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return ProjectResource::collection($projects);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(ProjectRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Project::class);
|
||||||
|
|
||||||
|
$project = $this->save(new Project, $request);
|
||||||
|
|
||||||
|
return ProjectResource::make($project)->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Project $project): ProjectResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $project);
|
||||||
|
|
||||||
|
return ProjectResource::make($project->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(ProjectRequest $request, Project $project): ProjectResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $project);
|
||||||
|
|
||||||
|
return ProjectResource::make($this->save($project, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Project $project): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $project);
|
||||||
|
|
||||||
|
$project->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(Project $project): ProjectResource
|
||||||
|
{
|
||||||
|
$this->authorize('restore', $project);
|
||||||
|
|
||||||
|
$project->restore();
|
||||||
|
|
||||||
|
return ProjectResource::make($project->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The record and its relations in one transaction: a project that saved
|
||||||
|
* but lost its team halfway would be worse than one that did not save.
|
||||||
|
*/
|
||||||
|
protected function save(Project $project, ProjectRequest $request): Project
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($project, $data): void {
|
||||||
|
$project->fill(TranslatablePayload::merge(
|
||||||
|
$project,
|
||||||
|
collect($data)->except(['technology_ids', 'service_ids', 'members'])->all(),
|
||||||
|
$project->translatable,
|
||||||
|
));
|
||||||
|
$project->save();
|
||||||
|
|
||||||
|
if (array_key_exists('technology_ids', $data)) {
|
||||||
|
$project->technologies()->sync($data['technology_ids']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('service_ids', $data)) {
|
||||||
|
$project->services()->sync($data['service_ids']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('members', $data)) {
|
||||||
|
$project->members()->sync(collect($data['members'])->mapWithKeys(
|
||||||
|
fn (array $member) => [$member['id'] => ['role' => $member['role'] ?? null]],
|
||||||
|
)->all());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $project->load(self::WITH);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
app/Http/Controllers/Api/V1/RedirectController.php
Normal file
77
app/Http/Controllers/Api/V1/RedirectController.php
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\RedirectRequest;
|
||||||
|
use App\Http\Resources\RedirectResource;
|
||||||
|
use App\Models\Redirect;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Old addresses and where they go now.
|
||||||
|
*/
|
||||||
|
class RedirectController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Filters: is_enabled, locale, automatic (left behind by a slug rename),
|
||||||
|
* search (either end).
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Redirect::class);
|
||||||
|
|
||||||
|
$redirects = Redirect::query()
|
||||||
|
->when($request->has('is_enabled'), fn ($q) => $q->where('is_enabled', $request->boolean('is_enabled')))
|
||||||
|
->when($request->filled('locale'), fn ($q) => $q->where('locale', $request->string('locale')))
|
||||||
|
->when($request->has('automatic'), fn ($q) => $request->boolean('automatic')
|
||||||
|
? $q->whereNotNull('source_type')
|
||||||
|
: $q->whereNull('source_type'))
|
||||||
|
->when($request->filled('search'), function ($q) use ($request): void {
|
||||||
|
$term = '%'.$request->string('search').'%';
|
||||||
|
|
||||||
|
$q->where(fn ($q) => $q->where('from', 'like', $term)->orWhere('to', 'like', $term));
|
||||||
|
})
|
||||||
|
->latest()
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return RedirectResource::collection($redirects);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(RedirectRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Redirect::class);
|
||||||
|
|
||||||
|
return RedirectResource::make(Redirect::create($request->validated())->refresh())->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Redirect $redirect): RedirectResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $redirect);
|
||||||
|
|
||||||
|
return RedirectResource::make($redirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(RedirectRequest $request, Redirect $redirect): RedirectResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $redirect);
|
||||||
|
|
||||||
|
$redirect->update($request->validated());
|
||||||
|
|
||||||
|
return RedirectResource::make($redirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Redirect $redirect): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $redirect);
|
||||||
|
|
||||||
|
$redirect->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
app/Http/Controllers/Api/V1/RepositoryController.php
Normal file
87
app/Http/Controllers/Api/V1/RepositoryController.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\RepositoryRequest;
|
||||||
|
use App\Http\Resources\RepositoryResource;
|
||||||
|
use App\Jobs\SyncRepository;
|
||||||
|
use App\Models\Repository;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The collective's git repositories. Only which repository, whose project and
|
||||||
|
* whether to show it are written here; everything else is what the provider
|
||||||
|
* last reported.
|
||||||
|
*/
|
||||||
|
class RepositoryController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Filters: provider, project_id, show_publicly, failing, search (full name).
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Repository::class);
|
||||||
|
|
||||||
|
$repositories = Repository::query()
|
||||||
|
->when($request->filled('provider'), fn ($q) => $q->where('provider', $request->string('provider')))
|
||||||
|
->when($request->filled('project_id'), fn ($q) => $q->where('project_id', $request->integer('project_id')))
|
||||||
|
->when($request->has('show_publicly'), fn ($q) => $q->where('show_publicly', $request->boolean('show_publicly')))
|
||||||
|
->when($request->boolean('failing'), fn ($q) => $q->failing())
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where('full_name', 'like', '%'.$request->string('search').'%'))
|
||||||
|
->orderBy('full_name')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return RepositoryResource::collection($repositories);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(RepositoryRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Repository::class);
|
||||||
|
|
||||||
|
return RepositoryResource::make(Repository::create($request->validated())->refresh())->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Repository $repository): RepositoryResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $repository);
|
||||||
|
|
||||||
|
return RepositoryResource::make($repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(RepositoryRequest $request, Repository $repository): RepositoryResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $repository);
|
||||||
|
|
||||||
|
$repository->update($request->validated());
|
||||||
|
|
||||||
|
return RepositoryResource::make($repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Repository $repository): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $repository);
|
||||||
|
|
||||||
|
$repository->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the provider again, on the queue: a sync spends a rate limit the
|
||||||
|
* whole application shares, and a caller should not wait on it.
|
||||||
|
*/
|
||||||
|
public function sync(Repository $repository): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('sync', $repository);
|
||||||
|
|
||||||
|
SyncRepository::dispatch($repository);
|
||||||
|
|
||||||
|
return RepositoryResource::make($repository)->response()->setStatusCode(202);
|
||||||
|
}
|
||||||
|
}
|
||||||
187
app/Http/Controllers/Api/V1/ReviewController.php
Normal file
187
app/Http/Controllers/Api/V1/ReviewController.php
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\V1\Concerns\ResolvesContent;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\ReviewCommentResource;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\AdminContent;
|
||||||
|
use App\Support\ContentTypes;
|
||||||
|
use App\Support\Publisher;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The handover between writing something and putting it on the site — the
|
||||||
|
* panel's review buttons, as calls. Pages, projects and services only: the
|
||||||
|
* kinds with a separate `publish` permission.
|
||||||
|
*/
|
||||||
|
class ReviewController extends Controller
|
||||||
|
{
|
||||||
|
use ResolvesContent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is waiting for the caller: everything awaiting review they could
|
||||||
|
* answer, either addressed to them or to nobody in particular.
|
||||||
|
*/
|
||||||
|
public function queue(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$items = collect();
|
||||||
|
|
||||||
|
foreach (array_keys(AdminContent::REVIEWABLE) as $resource) {
|
||||||
|
$model = AdminContent::model($resource);
|
||||||
|
|
||||||
|
if ($user->cannot("{$resource}.publish")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$model::query()
|
||||||
|
->awaitingReviewBy($user)
|
||||||
|
->with(['reviewRequester', 'reviewer'])
|
||||||
|
->get()
|
||||||
|
->each(fn (Model $record) => $items->push($this->summary($resource, $record)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $items->sortBy('requested_at')->values(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The thread: requests, returns, publications and comments, newest first.
|
||||||
|
*/
|
||||||
|
public function thread(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$record = $this->record($request);
|
||||||
|
|
||||||
|
$this->authorize('view', $record);
|
||||||
|
|
||||||
|
return ReviewCommentResource::collection(
|
||||||
|
$record->reviewComments()->with('user')->paginate(perPage: min($request->integer('per_page', 25), 100)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask for it to be reviewed — the only way somebody without the publish
|
||||||
|
* permission has of putting their work live.
|
||||||
|
*/
|
||||||
|
public function request(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$record = $this->record($request);
|
||||||
|
|
||||||
|
$this->authorize('update', $record);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'reviewer_id' => ['sometimes', 'nullable', 'integer', 'exists:users,id'],
|
||||||
|
'note' => ['sometimes', 'nullable', 'string', 'max:2000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($record->isAwaitingReview() || $record->isPublishedNow()) {
|
||||||
|
throw ValidationException::withMessages(['review' => [__('rules.reviews.not_requestable')]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reviewer = isset($data['reviewer_id']) ? User::find($data['reviewer_id']) : null;
|
||||||
|
|
||||||
|
if ($reviewer !== null && $reviewer->cannot($record->getTable().'.publish')) {
|
||||||
|
throw ValidationException::withMessages(['reviewer_id' => [__('rules.reviews.not_a_reviewer')]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->requestReview($reviewer, $data['note'] ?? null, $request->user());
|
||||||
|
|
||||||
|
return $this->answer($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send it back to whoever asked, saying why.
|
||||||
|
*/
|
||||||
|
public function return(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$record = $this->record($request);
|
||||||
|
|
||||||
|
$this->authorize($record->getTable().'.publish');
|
||||||
|
|
||||||
|
$data = $request->validate(['note' => ['required', 'string', 'max:2000']]);
|
||||||
|
|
||||||
|
if (! $record->isAwaitingReview()) {
|
||||||
|
throw ValidationException::withMessages(['review' => [__('rules.reviews.not_awaiting')]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->returnForChanges($data['note'], $request->user());
|
||||||
|
|
||||||
|
return $this->answer($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function comment(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$record = $this->record($request);
|
||||||
|
|
||||||
|
$this->authorize('view', $record);
|
||||||
|
|
||||||
|
$data = $request->validate(['body' => ['required', 'string', 'max:2000']]);
|
||||||
|
|
||||||
|
return ReviewCommentResource::make($record->comment($data['body'], $request->user())->load('user'))
|
||||||
|
->response()
|
||||||
|
->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve: on the site now, or from `at`. Either way the review, if there
|
||||||
|
* was one, is answered.
|
||||||
|
*/
|
||||||
|
public function publish(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$record = $this->record($request);
|
||||||
|
|
||||||
|
$this->authorize($record->getTable().'.publish');
|
||||||
|
|
||||||
|
$data = $request->validate(['at' => ['sometimes', 'nullable', 'date']]);
|
||||||
|
|
||||||
|
Publisher::publish($record, filled($data['at'] ?? null) ? CarbonImmutable::parse($data['at']) : null);
|
||||||
|
|
||||||
|
return $this->answer($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unpublish(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$record = $this->record($request);
|
||||||
|
|
||||||
|
$this->authorize($record->getTable().'.publish');
|
||||||
|
|
||||||
|
Publisher::unpublish($record);
|
||||||
|
|
||||||
|
return $this->answer($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function answer(Model $record): JsonResponse
|
||||||
|
{
|
||||||
|
$record->refresh()->load(['reviewRequester', 'reviewer']);
|
||||||
|
|
||||||
|
return response()->json(['data' => $this->summary(AdminContent::nameOf($record), $record)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function summary(string $resource, Model $record): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => $resource,
|
||||||
|
'id' => $record->getKey(),
|
||||||
|
'title' => ContentTypes::title($record),
|
||||||
|
'published' => $record->isPublishedNow(),
|
||||||
|
'published_at' => $record->published_at?->toIso8601String(),
|
||||||
|
'awaiting_review' => $record->isAwaitingReview(),
|
||||||
|
'requested_at' => $record->review_requested_at?->toIso8601String(),
|
||||||
|
'requested_by' => $record->reviewRequester ? ['id' => $record->reviewRequester->id, 'name' => $record->reviewRequester->name] : null,
|
||||||
|
'reviewer' => $record->reviewer ? ['id' => $record->reviewer->id, 'name' => $record->reviewer->name] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/Http/Controllers/Api/V1/RevisionController.php
Normal file
74
app/Http/Controllers/Api/V1/RevisionController.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\V1\Concerns\ResolvesContent;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\RevisionResource;
|
||||||
|
use App\Models\Revision;
|
||||||
|
use App\Support\AdminContent;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A record's history: who changed what and when, and undoing one change.
|
||||||
|
*/
|
||||||
|
class RevisionController extends Controller
|
||||||
|
{
|
||||||
|
use ResolvesContent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Newest first. Filter: event.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$record = $this->record($request);
|
||||||
|
|
||||||
|
$this->authorize('view', $record);
|
||||||
|
|
||||||
|
$revisions = $record->revisions()
|
||||||
|
->with('user')
|
||||||
|
->when($request->filled('event'), fn ($q) => $q->where('event', $request->string('event')))
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return RevisionResource::collection($revisions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put back what one revision changed, as the panel's history does.
|
||||||
|
* Restoring is itself a change, so it lands in the history too — the way
|
||||||
|
* back is never lost.
|
||||||
|
*/
|
||||||
|
public function restore(Request $request, Revision $revision): RevisionResource
|
||||||
|
{
|
||||||
|
$model = Relation::getMorphedModel($revision->revisionable_type) ?? $revision->revisionable_type;
|
||||||
|
$record = AdminContent::findOf($model, $revision->revisionable_id);
|
||||||
|
|
||||||
|
$this->authorize('update', $record);
|
||||||
|
|
||||||
|
$values = $revision->previousValues();
|
||||||
|
|
||||||
|
if ($values === []) {
|
||||||
|
throw ValidationException::withMessages(['revision' => [__('rules.revisions.nothing_to_restore')]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undoing a change can publish or unpublish, and that stays with whoever
|
||||||
|
// may publish.
|
||||||
|
$guarded = array_intersect(array_keys($values), AdminContent::publicationFields($record));
|
||||||
|
|
||||||
|
if ($guarded !== [] && $request->user()->cannot($record->getTable().'.publish')) {
|
||||||
|
throw ValidationException::withMessages(['revision' => [__('rules.publication.forbidden')]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only what the model lets a form write: a revision may mention
|
||||||
|
// bookkeeping columns, and those are not anybody's to put back.
|
||||||
|
$record->forceFill(array_intersect_key($values, array_flip($record->getFillable())))->save();
|
||||||
|
|
||||||
|
return RevisionResource::make($record->revisions()->with('user')->first());
|
||||||
|
}
|
||||||
|
}
|
||||||
107
app/Http/Controllers/Api/V1/ServiceController.php
Normal file
107
app/Http/Controllers/Api/V1/ServiceController.php
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\ServiceRequest;
|
||||||
|
use App\Http\Resources\ServiceResource;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the collective offers, as the panel edits it.
|
||||||
|
*/
|
||||||
|
class ServiceController extends Controller
|
||||||
|
{
|
||||||
|
protected const WITH = ['technologies', 'projects'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: is_published, search (title in the active language), trashed.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Service::class);
|
||||||
|
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
|
||||||
|
$services = Service::query()
|
||||||
|
->with(self::WITH)
|
||||||
|
->when($request->has('is_published'), fn ($q) => $q->where('is_published', $request->boolean('is_published')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where("title->{$locale}", 'like', '%'.$request->string('search').'%'))
|
||||||
|
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
|
||||||
|
->orderBy('position')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return ServiceResource::collection($services);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(ServiceRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Service::class);
|
||||||
|
|
||||||
|
return ServiceResource::make($this->save(new Service, $request))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Service $service): ServiceResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $service);
|
||||||
|
|
||||||
|
return ServiceResource::make($service->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(ServiceRequest $request, Service $service): ServiceResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $service);
|
||||||
|
|
||||||
|
return ServiceResource::make($this->save($service, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Service $service): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $service);
|
||||||
|
|
||||||
|
$service->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(Service $service): ServiceResource
|
||||||
|
{
|
||||||
|
$this->authorize('restore', $service);
|
||||||
|
|
||||||
|
$service->restore();
|
||||||
|
|
||||||
|
return ServiceResource::make($service->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function save(Service $service, ServiceRequest $request): Service
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($service, $data): void {
|
||||||
|
$service->fill(TranslatablePayload::merge(
|
||||||
|
$service,
|
||||||
|
collect($data)->except(['technology_ids', 'project_ids'])->all(),
|
||||||
|
$service->translatable,
|
||||||
|
));
|
||||||
|
$service->save();
|
||||||
|
|
||||||
|
if (array_key_exists('technology_ids', $data)) {
|
||||||
|
$service->technologies()->sync($data['technology_ids']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('project_ids', $data)) {
|
||||||
|
$service->projects()->sync($data['project_ids']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $service->load(self::WITH);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Http/Controllers/Api/V1/SettingController.php
Normal file
56
app/Http/Controllers/Api/V1/SettingController.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\SettingResource;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Support\ContentVersion;
|
||||||
|
use App\Support\Maintenance;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime settings, as the panel's settings screen shows them: the ones that
|
||||||
|
* exist, each value as the JSON it is. None is created or deleted from here.
|
||||||
|
*/
|
||||||
|
class SettingController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Written by the application, never by hand: a hand-set content version
|
||||||
|
* would leave the frontend's cache serving stale pages or refetching them
|
||||||
|
* for nothing.
|
||||||
|
*/
|
||||||
|
protected const READ_ONLY = [ContentVersion::KEY];
|
||||||
|
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('settings.view');
|
||||||
|
|
||||||
|
return SettingResource::collection(Setting::query()->orderBy('key')->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Setting $setting): SettingResource
|
||||||
|
{
|
||||||
|
$this->authorize('settings.view');
|
||||||
|
|
||||||
|
return SettingResource::make($setting);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Setting $setting): SettingResource
|
||||||
|
{
|
||||||
|
// Taking the site down is its own permission, in the panel as here.
|
||||||
|
$this->authorize($setting->key === Maintenance::KEY ? 'settings.maintenance' : 'settings.update');
|
||||||
|
|
||||||
|
abort_if(in_array($setting->key, self::READ_ONLY, true), 403, __('rules.settings.read_only'));
|
||||||
|
|
||||||
|
// `present` rather than `required`: null is a value a setting may hold.
|
||||||
|
$request->validate(['value' => ['present']]);
|
||||||
|
|
||||||
|
$setting->update(['value' => $request->input('value')]);
|
||||||
|
|
||||||
|
return SettingResource::make($setting);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
app/Http/Controllers/Api/V1/TagController.php
Normal file
82
app/Http/Controllers/Api/V1/TagController.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Tag;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use App\Rules\UniqueTranslatedSlug;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blog tags. Small enough that the whole list is one answer.
|
||||||
|
*/
|
||||||
|
class TagController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Tag::class);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => Tag::query()->withCount('pages')->orderBy('id')->get()->map(fn (Tag $tag) => $this->present($tag))->values(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Tag::class);
|
||||||
|
|
||||||
|
$tag = new Tag;
|
||||||
|
$tag->fill(TranslatablePayload::merge($tag, $this->validated($request, null), $tag->translatable))->save();
|
||||||
|
|
||||||
|
return response()->json(['data' => $this->present($tag->loadCount('pages'))], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Tag $tag): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('update', $tag);
|
||||||
|
|
||||||
|
$tag->fill(TranslatablePayload::merge($tag, $this->validated($request, $tag), $tag->translatable))->save();
|
||||||
|
|
||||||
|
return response()->json(['data' => $this->present($tag->loadCount('pages'))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Tag $tag): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $tag);
|
||||||
|
|
||||||
|
$tag->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function validated(Request $request, ?Tag $tag): array
|
||||||
|
{
|
||||||
|
$sometimes = $tag === null ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return $request->validate([
|
||||||
|
'name' => [$sometimes, new Translatable(requireDefaultLocale: $tag === null, max: 80)],
|
||||||
|
'slug' => [$sometimes, new Translatable(requireDefaultLocale: $tag === null, max: 80), new UniqueTranslatedSlug(Tag::class, $tag?->id)],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function present(Tag $tag): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $tag->id,
|
||||||
|
'name' => (object) $tag->getTranslations('name'),
|
||||||
|
'slug' => (object) $tag->getTranslations('slug'),
|
||||||
|
'pages_count' => (int) ($tag->pages_count ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/Http/Controllers/Api/V1/TalkController.php
Normal file
81
app/Http/Controllers/Api/V1/TalkController.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\TalkRequest;
|
||||||
|
use App\Http\Resources\TalkResource;
|
||||||
|
use App\Models\Talk;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talks given elsewhere. Deleted for good: there is no bin for them.
|
||||||
|
*/
|
||||||
|
class TalkController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Filters: is_public, member_id, project_id, search (title or event).
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Talk::class);
|
||||||
|
|
||||||
|
$talks = Talk::query()
|
||||||
|
->when($request->has('is_public'), fn ($q) => $q->where('is_public', $request->boolean('is_public')))
|
||||||
|
->when($request->filled('member_id'), fn ($q) => $q->where('member_id', $request->integer('member_id')))
|
||||||
|
->when($request->filled('project_id'), fn ($q) => $q->where('project_id', $request->integer('project_id')))
|
||||||
|
->when($request->filled('search'), function ($q) use ($request): void {
|
||||||
|
$term = '%'.$request->string('search').'%';
|
||||||
|
|
||||||
|
$q->where(fn ($q) => $q->where('title', 'like', $term)->orWhere('event', 'like', $term));
|
||||||
|
})
|
||||||
|
->recentFirst()
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return TalkResource::collection($talks);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(TalkRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Talk::class);
|
||||||
|
|
||||||
|
return TalkResource::make($this->save(new Talk, $request))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Talk $talk): TalkResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $talk);
|
||||||
|
|
||||||
|
return TalkResource::make($talk);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(TalkRequest $request, Talk $talk): TalkResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $talk);
|
||||||
|
|
||||||
|
return TalkResource::make($this->save($talk, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Talk $talk): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $talk);
|
||||||
|
|
||||||
|
$talk->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function save(Talk $talk, TalkRequest $request): Talk
|
||||||
|
{
|
||||||
|
$talk->fill(TranslatablePayload::merge($talk, $request->validated(), $talk->translatable));
|
||||||
|
$talk->save();
|
||||||
|
|
||||||
|
return $talk;
|
||||||
|
}
|
||||||
|
}
|
||||||
93
app/Http/Controllers/Api/V1/TechnologyController.php
Normal file
93
app/Http/Controllers/Api/V1/TechnologyController.php
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\TechnologyCategory;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\TechnologyRequest;
|
||||||
|
use App\Http\Resources\TechnologyResource;
|
||||||
|
use App\Models\Technology;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The technologies the collective uses, and their place on the radar.
|
||||||
|
*
|
||||||
|
* Same rules as the panel: TechnologyPolicy refuses to delete one a project,
|
||||||
|
* member or call still points at.
|
||||||
|
*/
|
||||||
|
class TechnologyController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Filters: category, radar_ring (`none` for those off the radar), search.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Technology::class);
|
||||||
|
|
||||||
|
$technologies = Technology::query()
|
||||||
|
->with('logoMedia')
|
||||||
|
->withCount('projects')
|
||||||
|
->when($request->filled('category'), fn ($q) => $q->where('category', $request->string('category')))
|
||||||
|
->when($request->filled('radar_ring'), fn ($q) => $request->string('radar_ring')->toString() === 'none'
|
||||||
|
? $q->whereNull('radar_ring')
|
||||||
|
: $q->where('radar_ring', $request->string('radar_ring')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where('name', 'like', '%'.$request->string('search').'%'))
|
||||||
|
->orderBy('position')
|
||||||
|
->orderBy('name')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return TechnologyResource::collection($technologies);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(TechnologyRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Technology::class);
|
||||||
|
|
||||||
|
$technology = new Technology;
|
||||||
|
$technology->fill(TranslatablePayload::merge($technology, $request->validated(), $technology->translatable));
|
||||||
|
// The column's default, and the panel's, stated here so the model
|
||||||
|
// knows it too: otherwise the answer to a create without a category
|
||||||
|
// would be missing the one the row was given.
|
||||||
|
$technology->category ??= TechnologyCategory::Tool;
|
||||||
|
$technology->save();
|
||||||
|
|
||||||
|
return TechnologyResource::make($this->loaded($technology))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Technology $technology): TechnologyResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $technology);
|
||||||
|
|
||||||
|
return TechnologyResource::make($this->loaded($technology));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(TechnologyRequest $request, Technology $technology): TechnologyResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $technology);
|
||||||
|
|
||||||
|
$technology->fill(TranslatablePayload::merge($technology, $request->validated(), $technology->translatable));
|
||||||
|
$technology->save();
|
||||||
|
|
||||||
|
return TechnologyResource::make($this->loaded($technology));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Technology $technology): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $technology);
|
||||||
|
|
||||||
|
$technology->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function loaded(Technology $technology): Technology
|
||||||
|
{
|
||||||
|
return $technology->load('logoMedia')->loadCount('projects');
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/Http/Controllers/Api/V1/TestimonialController.php
Normal file
92
app/Http/Controllers/Api/V1/TestimonialController.php
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\TestimonialRequest;
|
||||||
|
use App\Http\Resources\TestimonialResource;
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What people the collective worked with said about it.
|
||||||
|
*/
|
||||||
|
class TestimonialController extends Controller
|
||||||
|
{
|
||||||
|
protected const WITH = ['avatarMedia'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: is_public, client_id, project_id, service_id, search (author), trashed.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Testimonial::class);
|
||||||
|
|
||||||
|
$testimonials = Testimonial::query()
|
||||||
|
->with(self::WITH)
|
||||||
|
->when($request->has('is_public'), fn ($q) => $q->where('is_public', $request->boolean('is_public')))
|
||||||
|
->when($request->filled('client_id'), fn ($q) => $q->where('client_id', $request->integer('client_id')))
|
||||||
|
->when($request->filled('project_id'), fn ($q) => $q->where('project_id', $request->integer('project_id')))
|
||||||
|
->when($request->filled('service_id'), fn ($q) => $q->where('service_id', $request->integer('service_id')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where('author_name', 'like', '%'.$request->string('search').'%'))
|
||||||
|
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
|
||||||
|
->orderBy('position')
|
||||||
|
->latest('id')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return TestimonialResource::collection($testimonials);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(TestimonialRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Testimonial::class);
|
||||||
|
|
||||||
|
return TestimonialResource::make($this->save(new Testimonial, $request))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Testimonial $testimonial): TestimonialResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $testimonial);
|
||||||
|
|
||||||
|
return TestimonialResource::make($testimonial->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(TestimonialRequest $request, Testimonial $testimonial): TestimonialResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $testimonial);
|
||||||
|
|
||||||
|
return TestimonialResource::make($this->save($testimonial, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Testimonial $testimonial): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $testimonial);
|
||||||
|
|
||||||
|
$testimonial->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(Testimonial $testimonial): TestimonialResource
|
||||||
|
{
|
||||||
|
$this->authorize('restore', $testimonial);
|
||||||
|
|
||||||
|
$testimonial->restore();
|
||||||
|
|
||||||
|
return TestimonialResource::make($testimonial->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function save(Testimonial $testimonial, TestimonialRequest $request): Testimonial
|
||||||
|
{
|
||||||
|
$testimonial->fill(TranslatablePayload::merge($testimonial, $request->validated(), $testimonial->translatable));
|
||||||
|
$testimonial->save();
|
||||||
|
|
||||||
|
return $testimonial->load(self::WITH);
|
||||||
|
}
|
||||||
|
}
|
||||||
146
app/Http/Controllers/Api/V1/WebhookController.php
Normal file
146
app/Http/Controllers/Api/V1/WebhookController.php
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\WebhookRequest;
|
||||||
|
use App\Http\Resources\WebhookDeliveryResource;
|
||||||
|
use App\Http\Resources\WebhookResource;
|
||||||
|
use App\Jobs\DeliverWebhook;
|
||||||
|
use App\Models\Webhook;
|
||||||
|
use App\Models\WebhookDelivery;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outgoing webhooks: where to send, which events, and how it has gone.
|
||||||
|
*/
|
||||||
|
class WebhookController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Webhook::class);
|
||||||
|
|
||||||
|
return WebhookResource::collection(
|
||||||
|
Webhook::query()->latest('id')->paginate(perPage: min($request->integer('per_page', 25), 100)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The secret is in the answer once, when it is made (or rotated), and
|
||||||
|
* never again.
|
||||||
|
*/
|
||||||
|
public function store(WebhookRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Webhook::class);
|
||||||
|
|
||||||
|
$secret = $request->input('secret') ?: Str::random(40);
|
||||||
|
|
||||||
|
$webhook = new Webhook([...$request->validated(), 'secret' => $secret]);
|
||||||
|
$webhook->forceFill(['created_by' => $request->user()->id])->save();
|
||||||
|
|
||||||
|
return WebhookResource::make($webhook->refresh())
|
||||||
|
->additional(['secret' => $secret])
|
||||||
|
->response()
|
||||||
|
->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Webhook $webhook): WebhookResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $webhook);
|
||||||
|
|
||||||
|
return WebhookResource::make($webhook);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switching it back on starts its failure count again.
|
||||||
|
*/
|
||||||
|
public function update(WebhookRequest $request, Webhook $webhook): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('update', $webhook);
|
||||||
|
|
||||||
|
$data = $request->validated();
|
||||||
|
$rotated = $request->boolean('rotate_secret') ? Str::random(40) : null;
|
||||||
|
unset($data['rotate_secret']);
|
||||||
|
|
||||||
|
$webhook->fill($data);
|
||||||
|
|
||||||
|
if ($rotated !== null) {
|
||||||
|
$webhook->secret = $rotated;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($webhook->isDirty('is_active') && $webhook->is_active) {
|
||||||
|
$webhook->consecutive_failures = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$webhook->save();
|
||||||
|
|
||||||
|
return WebhookResource::make($webhook)
|
||||||
|
->additional($rotated !== null ? ['secret' => $rotated] : [])
|
||||||
|
->response();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Webhook $webhook): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $webhook);
|
||||||
|
|
||||||
|
$webhook->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Newest first. Filter: status.
|
||||||
|
*/
|
||||||
|
public function deliveries(Request $request, Webhook $webhook): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('view', $webhook);
|
||||||
|
|
||||||
|
return WebhookDeliveryResource::collection(
|
||||||
|
$webhook->deliveries()
|
||||||
|
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `ping`, to check the receiving end before anything real happens.
|
||||||
|
*/
|
||||||
|
public function ping(Webhook $webhook): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('update', $webhook);
|
||||||
|
|
||||||
|
$delivery = $webhook->deliveries()->create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'event' => 'ping',
|
||||||
|
'payload' => ['webhook' => $webhook->id, 'events' => $webhook->events],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DeliverWebhook::dispatch($delivery);
|
||||||
|
|
||||||
|
return WebhookDeliveryResource::make($delivery->refresh())->response()->setStatusCode(202);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send one again, as a new delivery of the same event and payload.
|
||||||
|
*/
|
||||||
|
public function redeliver(WebhookDelivery $delivery): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('update', $delivery->webhook);
|
||||||
|
|
||||||
|
$again = $delivery->webhook->deliveries()->create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'event' => $delivery->event,
|
||||||
|
'payload' => $delivery->payload,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DeliverWebhook::dispatch($again);
|
||||||
|
|
||||||
|
return WebhookDeliveryResource::make($again->refresh())->response()->setStatusCode(202);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Http/Requests/Api/V1/ApplicationRequest.php
Normal file
32
app/Http/Requests/Api/V1/ApplicationRequest.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\ApplicationStatus;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the inbox lets anyone change about a submission: nothing the sender
|
||||||
|
* wrote, only how it is being handled.
|
||||||
|
*/
|
||||||
|
class ApplicationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => ['sometimes', Rule::enum(ApplicationStatus::class)],
|
||||||
|
'notes' => ['sometimes', 'nullable', 'string', 'max:10000'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Http/Requests/Api/V1/CallRequest.php
Normal file
58
app/Http/Requests/Api/V1/CallRequest.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\CallStatus;
|
||||||
|
use App\Models\Call;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use App\Rules\UniqueTranslatedSlug;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's call form, as rules. `technology_ids` is replaced whole when
|
||||||
|
* sent.
|
||||||
|
*/
|
||||||
|
class CallRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public const SENIORITIES = ['any', 'junior', 'mid', 'senior'];
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$call = $this->route('call');
|
||||||
|
$creating = ! $call instanceof Call;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => [$sometimes, new Translatable(requireDefaultLocale: $creating, max: 255)],
|
||||||
|
'slug' => [
|
||||||
|
$sometimes,
|
||||||
|
new Translatable(requireDefaultLocale: $creating, max: 255),
|
||||||
|
new UniqueTranslatedSlug(Call::class, $call?->id),
|
||||||
|
],
|
||||||
|
'description' => ['sometimes', 'nullable', new Translatable],
|
||||||
|
'what_you_get' => ['sometimes', 'nullable', new Translatable],
|
||||||
|
|
||||||
|
'status' => ['sometimes', Rule::enum(CallStatus::class)],
|
||||||
|
'hours_per_week' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:40'],
|
||||||
|
'duration_weeks' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||||
|
'seniority' => ['sometimes', 'nullable', Rule::in(self::SENIORITIES)],
|
||||||
|
'opens_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'closes_at' => ['sometimes', 'nullable', 'date', 'after_or_equal:opens_at'],
|
||||||
|
|
||||||
|
'project_id' => ['sometimes', 'nullable', 'integer', Rule::exists('projects', 'id')->whereNull('deleted_at')],
|
||||||
|
'technology_ids' => ['sometimes', 'array'],
|
||||||
|
'technology_ids.*' => ['integer', 'distinct', Rule::exists('technologies', 'id')],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Http/Requests/Api/V1/ClientRequest.php
Normal file
47
app/Http/Requests/Api/V1/ClientRequest.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's client form, as rules.
|
||||||
|
*/
|
||||||
|
class ClientRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$client = $this->route('client');
|
||||||
|
$creating = ! $client instanceof Client;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => [$sometimes, 'string', 'max:255'],
|
||||||
|
'slug' => [
|
||||||
|
$sometimes,
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
'alpha_dash:ascii',
|
||||||
|
Rule::unique('clients', 'slug')->ignore($client?->id),
|
||||||
|
],
|
||||||
|
'sector' => ['sometimes', 'nullable', new Translatable(max: 120)],
|
||||||
|
'logo' => ['sometimes', 'nullable', 'string', 'max:2048'],
|
||||||
|
'url' => ['sometimes', 'nullable', 'url', 'max:2048'],
|
||||||
|
'is_public' => ['sometimes', 'boolean'],
|
||||||
|
'position' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Http/Requests/Api/V1/Concerns/GuardsPublication.php
Normal file
33
app/Http/Requests/Api/V1/Concerns/GuardsPublication.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishing is a permission of its own, as in the panel — where these fields
|
||||||
|
* are disabled for whoever lacks it. Here they are refused, so someone who may
|
||||||
|
* write a record still cannot put it on the site, or take it off, alone.
|
||||||
|
*/
|
||||||
|
trait GuardsPublication
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $fields
|
||||||
|
*/
|
||||||
|
protected function guardPublication(Validator $validator, string $permission, array $fields): void
|
||||||
|
{
|
||||||
|
$validator->after(function (Validator $validator) use ($permission, $fields): void {
|
||||||
|
if ($this->user()?->can($permission)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
if ($this->has($field)) {
|
||||||
|
$validator->errors()->add($field, __('rules.publication.forbidden'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Http/Requests/Api/V1/MediaRequest.php
Normal file
46
app/Http/Requests/Api/V1/MediaRequest.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An image arrives one of two ways: as the request's `file` (multipart), or as
|
||||||
|
* a `url` the API downloads itself (JSON). Exactly one.
|
||||||
|
*
|
||||||
|
* What the declared type says is only a first filter. The controller looks at
|
||||||
|
* the bytes, because a client names the type of what it sends and a URL's
|
||||||
|
* server names the type of what it answers — and neither has to be telling
|
||||||
|
* the truth.
|
||||||
|
*/
|
||||||
|
class MediaRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Authorisation is the controller's, as everywhere else in this API — and
|
||||||
|
* it has to happen before a URL is fetched, which is after validation.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'file' => [
|
||||||
|
'required_without:url',
|
||||||
|
'prohibits:url',
|
||||||
|
'file',
|
||||||
|
'max:'.(int) config('media.upload.max_kb'),
|
||||||
|
'mimetypes:'.implode(',', (array) config('media.upload.mimes')),
|
||||||
|
],
|
||||||
|
'url' => ['required_without:file', 'string', 'url:https', 'max:2048'],
|
||||||
|
'for' => ['sometimes', 'string', 'in:'.implode(',', array_keys((array) config('media.upload.for')))],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/Http/Requests/Api/V1/MemberRequest.php
Normal file
66
app/Http/Requests/Api/V1/MemberRequest.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's member form, as rules. A name is a person's, the same in every
|
||||||
|
* language, and so is the slug built from it; only the headline and the bio
|
||||||
|
* are translatable.
|
||||||
|
*
|
||||||
|
* `links` is a label → URL map (`{"github": "https://…"}`). `technology_ids`
|
||||||
|
* is replaced whole when sent. Which projects someone is on is set from the
|
||||||
|
* project, where the role belongs.
|
||||||
|
*/
|
||||||
|
class MemberRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$member = $this->route('member');
|
||||||
|
$creating = ! $member instanceof Member;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => [$sometimes, 'string', 'max:255'],
|
||||||
|
'slug' => [
|
||||||
|
$sometimes,
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
'alpha_dash:ascii',
|
||||||
|
Rule::unique('members', 'slug')->ignore($member?->id),
|
||||||
|
],
|
||||||
|
'headline' => ['sometimes', 'nullable', new Translatable(max: 255)],
|
||||||
|
'bio' => ['sometimes', 'nullable', new Translatable],
|
||||||
|
|
||||||
|
'avatar' => ['sometimes', 'nullable', 'string', 'max:2048'],
|
||||||
|
// Never shown on the site; the panel keeps it for the collective.
|
||||||
|
'email' => ['sometimes', 'nullable', 'email', 'max:255'],
|
||||||
|
'github_username' => ['sometimes', 'nullable', 'string', 'max:120'],
|
||||||
|
'links' => ['sometimes', 'nullable', 'array', 'max:10'],
|
||||||
|
'links.*' => ['nullable', 'url', 'max:2048'],
|
||||||
|
|
||||||
|
'is_public' => ['sometimes', 'boolean'],
|
||||||
|
'is_founder' => ['sometimes', 'boolean'],
|
||||||
|
'joined_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'left_at' => ['sometimes', 'nullable', 'date', 'after_or_equal:joined_at'],
|
||||||
|
'position' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
|
||||||
|
'technology_ids' => ['sometimes', 'array'],
|
||||||
|
'technology_ids.*' => ['integer', 'distinct', Rule::exists('technologies', 'id')],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Http/Requests/Api/V1/MissingUrlRedirectRequest.php
Normal file
68
app/Http/Requests/Api/V1/MissingUrlRedirectRequest.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\MissingUrl;
|
||||||
|
use App\Models\Redirect;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a missing address should go. The origin is the address itself.
|
||||||
|
*/
|
||||||
|
class MissingUrlRedirectRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if (is_string($this->input('to')) && filled($this->input('to'))) {
|
||||||
|
$this->merge(['to' => Redirect::normalise($this->input('to'))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The language it was seen in, unless told otherwise; null for all.
|
||||||
|
if (! $this->exists('locale')) {
|
||||||
|
$this->merge(['locale' => $this->missingUrl()->locale]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$path = $this->missingUrl()->path;
|
||||||
|
$locale = $this->input('locale');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'to' => ['required', 'string', 'max:500', Rule::notIn([$path])],
|
||||||
|
'status' => ['sometimes', 'integer', Rule::in([301, 302])],
|
||||||
|
'locale' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
Rule::in(array_keys((array) config('locales.supported'))),
|
||||||
|
// Already answered by a rule: edit that one instead.
|
||||||
|
function (string $attribute, mixed $value, \Closure $fail) use ($path, $locale): void {
|
||||||
|
$exists = Redirect::query()
|
||||||
|
->where('from', $path)
|
||||||
|
->when($locale === null, fn ($q) => $q->whereNull('locale'), fn ($q) => $q->where('locale', $locale))
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
$fail(__('validation.unique', ['attribute' => 'from']));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function missingUrl(): MissingUrl
|
||||||
|
{
|
||||||
|
return $this->route('missing_url');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,10 @@ class PageRequest extends FormRequest
|
|||||||
'meta_description' => ['sometimes', 'nullable', new Translatable(max: 160)],
|
'meta_description' => ['sometimes', 'nullable', new Translatable(max: 160)],
|
||||||
|
|
||||||
'category_id' => ['sometimes', 'nullable', 'integer', Rule::exists('categories', 'id')->whereNull('deleted_at')],
|
'category_id' => ['sometimes', 'nullable', 'integer', Rule::exists('categories', 'id')->whereNull('deleted_at')],
|
||||||
|
// Who signs it on the site; replaced whole when sent, like the tags.
|
||||||
|
'byline_member_id' => ['sometimes', 'nullable', 'integer', Rule::exists('members', 'id')->whereNull('deleted_at')],
|
||||||
|
'tag_ids' => ['sometimes', 'array'],
|
||||||
|
'tag_ids.*' => ['integer', 'distinct', Rule::exists('tags', 'id')],
|
||||||
'status' => ['sometimes', Rule::enum(PageStatus::class)],
|
'status' => ['sometimes', Rule::enum(PageStatus::class)],
|
||||||
'featured_image' => ['sometimes', 'nullable', 'string', 'max:2048'],
|
'featured_image' => ['sometimes', 'nullable', 'string', 'max:2048'],
|
||||||
'featured_image_alt' => ['sometimes', 'nullable', new Translatable(max: 255)],
|
'featured_image_alt' => ['sometimes', 'nullable', new Translatable(max: 255)],
|
||||||
|
|||||||
84
app/Http/Requests/Api/V1/ProjectRequest.php
Normal file
84
app/Http/Requests/Api/V1/ProjectRequest.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\ProjectStatus;
|
||||||
|
use App\Http\Requests\Api\V1\Concerns\GuardsPublication;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use App\Rules\UniqueTranslatedSlug;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's project form, as rules. On update every field is optional
|
||||||
|
* (PATCH semantics) but keeps the same shape.
|
||||||
|
*
|
||||||
|
* Relations travel as ids and are replaced whole when sent: `technology_ids`,
|
||||||
|
* `service_ids`, and `members` with each one's role on the project.
|
||||||
|
*/
|
||||||
|
class ProjectRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use GuardsPublication;
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$project = $this->route('project');
|
||||||
|
$creating = ! $project instanceof Project;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => [$sometimes, new Translatable(requireDefaultLocale: $creating, max: 255)],
|
||||||
|
'slug' => [
|
||||||
|
$sometimes,
|
||||||
|
new Translatable(requireDefaultLocale: $creating, max: 255),
|
||||||
|
new UniqueTranslatedSlug(Project::class, $project?->id),
|
||||||
|
],
|
||||||
|
'tagline' => ['sometimes', 'nullable', new Translatable(max: 300)],
|
||||||
|
'description' => ['sometimes', 'nullable', new Translatable],
|
||||||
|
'decisions' => ['sometimes', 'nullable', new Translatable],
|
||||||
|
'postmortem' => ['sometimes', 'nullable', new Translatable],
|
||||||
|
'meta_title' => ['sometimes', 'nullable', new Translatable(max: 60)],
|
||||||
|
'meta_description' => ['sometimes', 'nullable', new Translatable(max: 160)],
|
||||||
|
|
||||||
|
'status' => ['sometimes', Rule::enum(ProjectStatus::class)],
|
||||||
|
'is_featured' => ['sometimes', 'boolean'],
|
||||||
|
'needs_help' => ['sometimes', 'boolean'],
|
||||||
|
'demo_url' => ['sometimes', 'nullable', 'url', 'max:2048'],
|
||||||
|
'cover_image' => ['sometimes', 'nullable', 'string', 'max:2048'],
|
||||||
|
'cover_image_alt' => ['sometimes', 'nullable', new Translatable(max: 255)],
|
||||||
|
'started_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'ended_at' => ['sometimes', 'nullable', 'date', 'after_or_equal:started_at'],
|
||||||
|
'published_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'unpublish_at' => ['sometimes', 'nullable', 'date', 'after:published_at'],
|
||||||
|
'position' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
|
||||||
|
'client_id' => ['sometimes', 'nullable', 'integer', Rule::exists('clients', 'id')->whereNull('deleted_at')],
|
||||||
|
'lead_member_id' => ['sometimes', 'nullable', 'integer', Rule::exists('members', 'id')->whereNull('deleted_at')],
|
||||||
|
|
||||||
|
'technology_ids' => ['sometimes', 'array'],
|
||||||
|
'technology_ids.*' => ['integer', 'distinct', Rule::exists('technologies', 'id')],
|
||||||
|
'service_ids' => ['sometimes', 'array'],
|
||||||
|
'service_ids.*' => ['integer', 'distinct', Rule::exists('services', 'id')->whereNull('deleted_at')],
|
||||||
|
'members' => ['sometimes', 'array'],
|
||||||
|
'members.*.id' => ['required', 'integer', 'distinct', Rule::exists('members', 'id')->whereNull('deleted_at')],
|
||||||
|
'members.*.role' => ['nullable', 'string', 'max:120'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withValidator(Validator $validator): void
|
||||||
|
{
|
||||||
|
$this->guardPublication($validator, 'projects.publish', ['published_at', 'unpublish_at']);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/Http/Requests/Api/V1/RedirectRequest.php
Normal file
67
app/Http/Requests/Api/V1/RedirectRequest.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Redirect;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's redirect form, as rules. Both ends are normalised before they
|
||||||
|
* are checked, so `/a/` and `/a` are the same rule however it was typed.
|
||||||
|
*/
|
||||||
|
class RedirectRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
foreach (['from', 'to'] as $end) {
|
||||||
|
if (is_string($this->input($end)) && filled($this->input($end))) {
|
||||||
|
$this->merge([$end => Redirect::normalise($this->input($end))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$redirect = $this->route('redirect');
|
||||||
|
$creating = ! $redirect instanceof Redirect;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
$from = $this->input('from', $redirect?->from);
|
||||||
|
$locale = $this->exists('locale') ? $this->input('locale') : $redirect?->locale;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'from' => [
|
||||||
|
$sometimes,
|
||||||
|
'string',
|
||||||
|
'max:500',
|
||||||
|
// One rule per address and language, as the table's index says.
|
||||||
|
Rule::unique('redirects', 'from')
|
||||||
|
->ignore($redirect?->id)
|
||||||
|
->where(fn ($q) => $locale === null ? $q->whereNull('locale') : $q->where('locale', $locale)),
|
||||||
|
],
|
||||||
|
'to' => [$sometimes, 'string', 'max:500', Rule::notIn([$from])],
|
||||||
|
'status' => ['sometimes', 'integer', Rule::in([301, 302])],
|
||||||
|
'locale' => ['sometimes', 'nullable', 'string', Rule::in(array_keys((array) config('locales.supported')))],
|
||||||
|
'is_enabled' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return ['to.not_in' => __('validation.different', ['attribute' => 'to', 'other' => 'from'])];
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Http/Requests/Api/V1/RepositoryRequest.php
Normal file
50
app/Http/Requests/Api/V1/RepositoryRequest.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\GitProvider;
|
||||||
|
use App\Models\Repository;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's repository form, as rules. The synced columns are not here on
|
||||||
|
* purpose: nothing a caller sends may fake a repository's numbers.
|
||||||
|
*/
|
||||||
|
class RepositoryRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$repository = $this->route('repository');
|
||||||
|
$creating = ! $repository instanceof Repository;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
$provider = $this->input('provider', $repository?->provider?->value);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'provider' => [$sometimes, Rule::enum(GitProvider::class)],
|
||||||
|
'full_name' => [
|
||||||
|
$sometimes,
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
'regex:/^[\w.-]+\/[\w.-]+$|^[\w.-]+$/',
|
||||||
|
Rule::unique('repositories', 'full_name')
|
||||||
|
->ignore($repository?->id)
|
||||||
|
->where('provider', $provider),
|
||||||
|
],
|
||||||
|
'project_id' => ['sometimes', 'nullable', 'integer', Rule::exists('projects', 'id')->whereNull('deleted_at')],
|
||||||
|
'is_primary' => ['sometimes', 'boolean'],
|
||||||
|
'show_publicly' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Http/Requests/Api/V1/ServiceRequest.php
Normal file
68
app/Http/Requests/Api/V1/ServiceRequest.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Requests\Api\V1\Concerns\GuardsPublication;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use App\Rules\UniqueTranslatedSlug;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's service form, as rules. `technology_ids` and `project_ids` are
|
||||||
|
* replaced whole when sent.
|
||||||
|
*/
|
||||||
|
class ServiceRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use GuardsPublication;
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$service = $this->route('service');
|
||||||
|
$creating = ! $service instanceof Service;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => [$sometimes, new Translatable(requireDefaultLocale: $creating, max: 255)],
|
||||||
|
'slug' => [
|
||||||
|
$sometimes,
|
||||||
|
new Translatable(requireDefaultLocale: $creating, max: 255),
|
||||||
|
new UniqueTranslatedSlug(Service::class, $service?->id),
|
||||||
|
],
|
||||||
|
'tagline' => ['sometimes', 'nullable', new Translatable(max: 255)],
|
||||||
|
'description' => ['sometimes', 'nullable', new Translatable],
|
||||||
|
'meta_title' => ['sometimes', 'nullable', new Translatable(max: 60)],
|
||||||
|
'meta_description' => ['sometimes', 'nullable', new Translatable(max: 160)],
|
||||||
|
|
||||||
|
// A name from the icon set the frontend resolves, e.g. `support`.
|
||||||
|
'icon' => ['sometimes', 'nullable', 'string', 'max:120'],
|
||||||
|
'is_featured' => ['sometimes', 'boolean'],
|
||||||
|
'position' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
'is_published' => ['sometimes', 'boolean'],
|
||||||
|
'published_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'unpublish_at' => ['sometimes', 'nullable', 'date', 'after:published_at'],
|
||||||
|
|
||||||
|
'technology_ids' => ['sometimes', 'array'],
|
||||||
|
'technology_ids.*' => ['integer', 'distinct', Rule::exists('technologies', 'id')],
|
||||||
|
'project_ids' => ['sometimes', 'array'],
|
||||||
|
'project_ids.*' => ['integer', 'distinct', Rule::exists('projects', 'id')->whereNull('deleted_at')],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withValidator(Validator $validator): void
|
||||||
|
{
|
||||||
|
$this->guardPublication($validator, 'services.publish', ['is_published', 'published_at', 'unpublish_at']);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Http/Requests/Api/V1/TalkRequest.php
Normal file
45
app/Http/Requests/Api/V1/TalkRequest.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Talk;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's talk form, as rules. The title is the one the talk was given
|
||||||
|
* under, in `language`; only the summary is translated.
|
||||||
|
*/
|
||||||
|
class TalkRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$creating = ! $this->route('talk') instanceof Talk;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => [$sometimes, 'string', 'max:255'],
|
||||||
|
'language' => [$sometimes, 'string', Rule::in(array_keys((array) config('locales.supported')))],
|
||||||
|
'summary' => ['sometimes', 'nullable', new Translatable(max: 500)],
|
||||||
|
'event' => [$sometimes, 'string', 'max:255'],
|
||||||
|
'city' => ['sometimes', 'nullable', 'string', 'max:120'],
|
||||||
|
'held_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'video_url' => ['sometimes', 'nullable', 'url', 'max:2048'],
|
||||||
|
'slides_url' => ['sometimes', 'nullable', 'url', 'max:2048'],
|
||||||
|
'member_id' => ['sometimes', 'nullable', 'integer', Rule::exists('members', 'id')->whereNull('deleted_at')],
|
||||||
|
'project_id' => ['sometimes', 'nullable', 'integer', Rule::exists('projects', 'id')->whereNull('deleted_at')],
|
||||||
|
'is_public' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Http/Requests/Api/V1/TechnologyRequest.php
Normal file
59
app/Http/Requests/Api/V1/TechnologyRequest.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\RadarRing;
|
||||||
|
use App\Enums\TechnologyCategory;
|
||||||
|
use App\Models\Technology;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's technology form, as rules. On update every field is optional
|
||||||
|
* (PATCH semantics) but keeps the same shape.
|
||||||
|
*
|
||||||
|
* `name` and `slug` are plain strings: a technology's name is a proper noun,
|
||||||
|
* the same in every language. Only the prose about it is translatable.
|
||||||
|
*/
|
||||||
|
class TechnologyRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$technology = $this->route('technology');
|
||||||
|
$creating = ! $technology instanceof Technology;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => [$sometimes, 'string', 'max:120'],
|
||||||
|
'slug' => [
|
||||||
|
$sometimes,
|
||||||
|
'string',
|
||||||
|
'max:120',
|
||||||
|
'alpha_dash:ascii',
|
||||||
|
Rule::unique('technologies', 'slug')->ignore($technology?->id),
|
||||||
|
],
|
||||||
|
'description' => ['sometimes', 'nullable', new Translatable(max: 1000)],
|
||||||
|
'radar_note' => ['sometimes', 'nullable', new Translatable(max: 1000)],
|
||||||
|
|
||||||
|
'category' => ['sometimes', Rule::enum(TechnologyCategory::class)],
|
||||||
|
// Null keeps it off the radar while projects can still be tagged with it.
|
||||||
|
'radar_ring' => ['sometimes', 'nullable', Rule::enum(RadarRing::class)],
|
||||||
|
'color' => ['sometimes', 'nullable', 'string', 'regex:/^#[0-9a-fA-F]{6}$/'],
|
||||||
|
'logo' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||||
|
// 255, not the panel's 2048: that is the column's width.
|
||||||
|
'website' => ['sometimes', 'nullable', 'url', 'max:255'],
|
||||||
|
'position' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Http/Requests/Api/V1/TestimonialRequest.php
Normal file
47
app/Http/Requests/Api/V1/TestimonialRequest.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's testimonial form, as rules.
|
||||||
|
*
|
||||||
|
* `original_locale` says which language the person actually spoke in; the
|
||||||
|
* other entries of `quote` are translations of it.
|
||||||
|
*/
|
||||||
|
class TestimonialRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$creating = ! $this->route('testimonial') instanceof Testimonial;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'quote' => [$sometimes, new Translatable(max: 1000)],
|
||||||
|
'original_locale' => [$sometimes, 'string', Rule::in(array_keys((array) config('locales.supported')))],
|
||||||
|
'author_name' => [$sometimes, 'string', 'max:255'],
|
||||||
|
'author_role' => ['sometimes', 'nullable', new Translatable(max: 255)],
|
||||||
|
'avatar' => ['sometimes', 'nullable', 'string', 'max:2048'],
|
||||||
|
'client_id' => ['sometimes', 'nullable', 'integer', Rule::exists('clients', 'id')->whereNull('deleted_at')],
|
||||||
|
'project_id' => ['sometimes', 'nullable', 'integer', Rule::exists('projects', 'id')->whereNull('deleted_at')],
|
||||||
|
'service_id' => ['sometimes', 'nullable', 'integer', Rule::exists('services', 'id')->whereNull('deleted_at')],
|
||||||
|
'is_public' => ['sometimes', 'boolean'],
|
||||||
|
'position' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
'given_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/Http/Requests/Api/V1/WebhookRequest.php
Normal file
53
app/Http/Requests/Api/V1/WebhookRequest.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Webhook;
|
||||||
|
use App\Support\Webhooks\PublicEndpoint;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class WebhookRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$creating = ! $this->route('webhook') instanceof Webhook;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => [$sometimes, 'string', 'max:120'],
|
||||||
|
'url' => [
|
||||||
|
$sometimes,
|
||||||
|
'string',
|
||||||
|
'url',
|
||||||
|
'max:2048',
|
||||||
|
// Refused now rather than at the first delivery; checked
|
||||||
|
// again at every delivery all the same.
|
||||||
|
function (string $attribute, mixed $value, Closure $fail): void {
|
||||||
|
[, $reason] = app(PublicEndpoint::class)->check((string) $value);
|
||||||
|
|
||||||
|
if ($reason !== null) {
|
||||||
|
$fail(__("rules.media.remote.{$reason}"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'events' => [$sometimes, 'array', 'min:1'],
|
||||||
|
'events.*' => ['string', 'distinct', Rule::in(['*', ...Webhook::EVENTS])],
|
||||||
|
'secret' => [$creating ? 'sometimes' : 'prohibited', 'nullable', 'string', 'min:16', 'max:255'],
|
||||||
|
'rotate_secret' => [$creating ? 'prohibited' : 'sometimes', 'boolean'],
|
||||||
|
'include_personal_data' => ['sometimes', 'boolean'],
|
||||||
|
'is_active' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Http/Resources/ApiTokenResource.php
Normal file
40
app/Http/Resources/ApiTokenResource.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\ApiToken;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Never the token itself, which only exists in the answer that made it.
|
||||||
|
*
|
||||||
|
* @mixin ApiToken
|
||||||
|
*/
|
||||||
|
class ApiTokenResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'prefix' => $this->prefix,
|
||||||
|
'abilities' => $this->abilities ?? [],
|
||||||
|
'owner' => ['id' => $this->user_id, 'name' => $this->user?->name],
|
||||||
|
'status' => match (true) {
|
||||||
|
$this->revoked_at !== null => 'revoked',
|
||||||
|
$this->expires_at->isPast() => 'expired',
|
||||||
|
default => 'active',
|
||||||
|
},
|
||||||
|
'expires_at' => $this->expires_at->toIso8601String(),
|
||||||
|
'last_used_at' => $this->last_used_at?->toIso8601String(),
|
||||||
|
'revoked_at' => $this->revoked_at?->toIso8601String(),
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Http/Resources/ApplicationResource.php
Normal file
49
app/Http/Resources/ApplicationResource.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Application;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Application
|
||||||
|
*/
|
||||||
|
class ApplicationResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* `ip_hash` stays out: it exists to spot the same sender twice, not to be
|
||||||
|
* handed around.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'kind' => $this->kind->value,
|
||||||
|
'status' => $this->status->value,
|
||||||
|
'call_id' => $this->call_id,
|
||||||
|
'subject' => $this->resource->subject(),
|
||||||
|
'name' => $this->name,
|
||||||
|
'email' => $this->email,
|
||||||
|
'organisation' => $this->organisation,
|
||||||
|
'message' => $this->message,
|
||||||
|
'hours_per_week' => $this->hours_per_week,
|
||||||
|
'links' => (object) ($this->links ?? []),
|
||||||
|
'locale' => $this->locale,
|
||||||
|
'notes' => $this->notes,
|
||||||
|
'handled_by' => $this->whenLoaded('handler', fn () => $this->handler ? [
|
||||||
|
'id' => $this->handler->id,
|
||||||
|
'name' => $this->handler->name,
|
||||||
|
] : null),
|
||||||
|
'handled_at' => $this->handled_at?->toIso8601String(),
|
||||||
|
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
'deleted_at' => $this->deleted_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Http/Resources/AuditLogResource.php
Normal file
37
app/Http/Resources/AuditLogResource.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin AuditLog
|
||||||
|
*/
|
||||||
|
class AuditLogResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'action' => $this->action,
|
||||||
|
'user' => ['id' => $this->user_id, 'name' => $this->user_name],
|
||||||
|
'via' => $this->via,
|
||||||
|
'api_token_id' => $this->api_token_id,
|
||||||
|
'ip' => $this->ip,
|
||||||
|
'subject' => $this->subject_type ? [
|
||||||
|
'type' => $this->subject_type,
|
||||||
|
'id' => $this->subject_id,
|
||||||
|
'label' => $this->subject_label,
|
||||||
|
] : null,
|
||||||
|
'changes' => $this->changes === null ? null : (object) $this->changes,
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Http/Resources/CallResource.php
Normal file
49
app/Http/Resources/CallResource.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Call;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Call
|
||||||
|
*/
|
||||||
|
class CallResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'locale' => $locale,
|
||||||
|
|
||||||
|
...TranslatablePayload::resolve($this->resource, $this->translatable, $locale),
|
||||||
|
|
||||||
|
'status' => $this->status->value,
|
||||||
|
'hours_per_week' => $this->hours_per_week,
|
||||||
|
'duration_weeks' => $this->duration_weeks,
|
||||||
|
'seniority' => $this->seniority,
|
||||||
|
'opens_at' => $this->opens_at?->toDateString(),
|
||||||
|
'closes_at' => $this->closes_at?->toDateString(),
|
||||||
|
'project_id' => $this->project_id,
|
||||||
|
'technology_ids' => $this->whenLoaded('technologies', fn () => $this->technologies->pluck('id')->values()),
|
||||||
|
'applications_count' => $this->whenCounted('applications'),
|
||||||
|
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
|
||||||
|
'translations' => $this->when(
|
||||||
|
in_array('translations', explode(',', (string) $request->query('with')), true),
|
||||||
|
fn () => TranslatablePayload::all($this->resource, $this->translatable),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Http/Resources/ClientResource.php
Normal file
54
app/Http/Resources/ClientResource.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Support\ImagePayload;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Client
|
||||||
|
*/
|
||||||
|
class ClientResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'locale' => $locale,
|
||||||
|
'name' => $this->name,
|
||||||
|
'slug' => $this->slug,
|
||||||
|
|
||||||
|
...TranslatablePayload::resolve($this->resource, $this->translatable, $locale),
|
||||||
|
|
||||||
|
'logo' => $this->logo,
|
||||||
|
'logo_media' => ImagePayload::make(
|
||||||
|
$this->logo,
|
||||||
|
$this->resource->relationLoaded('logoMedia') ? $this->resource->getRelation('logoMedia') : null,
|
||||||
|
$this->name,
|
||||||
|
),
|
||||||
|
'url' => $this->url,
|
||||||
|
'is_public' => (bool) $this->is_public,
|
||||||
|
'position' => $this->position,
|
||||||
|
'projects_count' => $this->whenCounted('projects'),
|
||||||
|
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
'deleted_at' => $this->deleted_at?->toIso8601String(),
|
||||||
|
|
||||||
|
'translations' => $this->when(
|
||||||
|
in_array('translations', explode(',', (string) $request->query('with')), true),
|
||||||
|
fn () => TranslatablePayload::all($this->resource, $this->translatable),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/Http/Resources/MediaResource.php
Normal file
43
app/Http/Resources/MediaResource.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Media;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Media
|
||||||
|
*/
|
||||||
|
class MediaResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
// What a record stores — `featured_image` on a page — and so the one
|
||||||
|
// field a caller has to keep.
|
||||||
|
'path' => $this->path,
|
||||||
|
'url' => $this->url(),
|
||||||
|
'mime' => $this->mime,
|
||||||
|
'size' => $this->size,
|
||||||
|
'width' => $this->width,
|
||||||
|
'height' => $this->height,
|
||||||
|
// False until the queue has derived the smaller versions and the
|
||||||
|
// placeholder. The image serves either way.
|
||||||
|
'processed' => $this->isProcessed(),
|
||||||
|
'error' => $this->error,
|
||||||
|
// Only in the library, where it decides whether a file may go.
|
||||||
|
'referenced' => $this->when(
|
||||||
|
array_key_exists('referenced', $this->resource->getAttributes()),
|
||||||
|
fn () => (bool) $this->resource->getAttribute('referenced'),
|
||||||
|
),
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/Http/Resources/MemberResource.php
Normal file
65
app/Http/Resources/MemberResource.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Support\ImagePayload;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Member
|
||||||
|
*/
|
||||||
|
class MemberResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'locale' => $locale,
|
||||||
|
'name' => $this->name,
|
||||||
|
'slug' => $this->slug,
|
||||||
|
|
||||||
|
...TranslatablePayload::resolve($this->resource, $this->translatable, $locale),
|
||||||
|
|
||||||
|
'avatar' => $this->avatar,
|
||||||
|
'avatar_media' => ImagePayload::make(
|
||||||
|
$this->avatar,
|
||||||
|
$this->resource->relationLoaded('avatarMedia') ? $this->resource->getRelation('avatarMedia') : null,
|
||||||
|
$this->name,
|
||||||
|
),
|
||||||
|
'email' => $this->email,
|
||||||
|
'github_username' => $this->github_username,
|
||||||
|
'links' => (object) ($this->links ?? []),
|
||||||
|
'is_public' => (bool) $this->is_public,
|
||||||
|
'is_founder' => (bool) $this->is_founder,
|
||||||
|
'joined_at' => $this->joined_at?->toDateString(),
|
||||||
|
'left_at' => $this->left_at?->toDateString(),
|
||||||
|
'position' => $this->position,
|
||||||
|
|
||||||
|
'technology_ids' => $this->whenLoaded('technologies', fn () => $this->technologies->pluck('id')->values()),
|
||||||
|
'projects' => $this->whenLoaded('projects', fn () => $this->projects->map(fn ($project) => [
|
||||||
|
'id' => $project->id,
|
||||||
|
'title' => $project->getTranslation('title', $locale),
|
||||||
|
'role' => $project->pivot->role,
|
||||||
|
])->values()),
|
||||||
|
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
'deleted_at' => $this->deleted_at?->toIso8601String(),
|
||||||
|
|
||||||
|
'translations' => $this->when(
|
||||||
|
in_array('translations', explode(',', (string) $request->query('with')), true),
|
||||||
|
fn () => TranslatablePayload::all($this->resource, $this->translatable),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Http/Resources/MissingUrlResource.php
Normal file
35
app/Http/Resources/MissingUrlResource.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\MissingUrl;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin MissingUrl
|
||||||
|
*/
|
||||||
|
class MissingUrlResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'path' => $this->path,
|
||||||
|
'locale' => $this->locale,
|
||||||
|
'referrer' => $this->referrer,
|
||||||
|
// Our own page links here: more urgent than someone else's stale link.
|
||||||
|
'internal_referrer' => $this->resource->isInternalReferrer(),
|
||||||
|
'status' => $this->status->value,
|
||||||
|
'hits' => (int) $this->hits,
|
||||||
|
'first_seen_at' => $this->first_seen_at?->toIso8601String(),
|
||||||
|
'last_seen_at' => $this->last_seen_at?->toIso8601String(),
|
||||||
|
'resolved_at' => $this->resolved_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,8 @@ class PageResource extends JsonResource
|
|||||||
|
|
||||||
'category' => CategoryResource::make($this->whenLoaded('category')),
|
'category' => CategoryResource::make($this->whenLoaded('category')),
|
||||||
'author' => UserResource::make($this->whenLoaded('author')),
|
'author' => UserResource::make($this->whenLoaded('author')),
|
||||||
|
'byline_member_id' => $this->byline_member_id,
|
||||||
|
'tag_ids' => $this->whenLoaded('tags', fn () => $this->tags->pluck('id')->values()),
|
||||||
|
|
||||||
// Opt-in: `?with=translations` returns every language at once,
|
// Opt-in: `?with=translations` returns every language at once,
|
||||||
// which is what a translation UI needs and a page renderer does not.
|
// which is what a translation UI needs and a page renderer does not.
|
||||||
|
|||||||
70
app/Http/Resources/ProjectResource.php
Normal file
70
app/Http/Resources/ProjectResource.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Support\ImagePayload;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Project
|
||||||
|
*/
|
||||||
|
class ProjectResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
|
||||||
|
$resolved = TranslatablePayload::resolve($this->resource, $this->translatable, $locale);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'locale' => $locale,
|
||||||
|
|
||||||
|
...$resolved,
|
||||||
|
|
||||||
|
'status' => $this->status->value,
|
||||||
|
'is_featured' => (bool) $this->is_featured,
|
||||||
|
'needs_help' => (bool) $this->needs_help,
|
||||||
|
'demo_url' => $this->demo_url,
|
||||||
|
// The stored path, which is what a client PATCHes back; what was
|
||||||
|
// derived from it travels alongside, as on a page.
|
||||||
|
'cover_image' => $this->cover_image,
|
||||||
|
'cover_image_media' => ImagePayload::make(
|
||||||
|
$this->cover_image,
|
||||||
|
$this->resource->relationLoaded('coverMedia') ? $this->resource->getRelation('coverMedia') : null,
|
||||||
|
$resolved['cover_image_alt'] ?? null,
|
||||||
|
),
|
||||||
|
'started_at' => $this->started_at?->toDateString(),
|
||||||
|
'ended_at' => $this->ended_at?->toDateString(),
|
||||||
|
'published_at' => $this->published_at?->toIso8601String(),
|
||||||
|
'unpublish_at' => $this->unpublish_at?->toIso8601String(),
|
||||||
|
'position' => $this->position,
|
||||||
|
|
||||||
|
'client_id' => $this->client_id,
|
||||||
|
'lead_member_id' => $this->lead_member_id,
|
||||||
|
'technology_ids' => $this->whenLoaded('technologies', fn () => $this->technologies->pluck('id')->values()),
|
||||||
|
'service_ids' => $this->whenLoaded('services', fn () => $this->services->pluck('id')->values()),
|
||||||
|
'members' => $this->whenLoaded('members', fn () => $this->members->map(fn ($member) => [
|
||||||
|
'id' => $member->id,
|
||||||
|
'name' => $member->name,
|
||||||
|
'role' => $member->pivot->role,
|
||||||
|
])->values()),
|
||||||
|
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
'deleted_at' => $this->deleted_at?->toIso8601String(),
|
||||||
|
|
||||||
|
'translations' => $this->when(
|
||||||
|
in_array('translations', explode(',', (string) $request->query('with')), true),
|
||||||
|
fn () => TranslatablePayload::all($this->resource, $this->translatable),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Resources\PublicApi;
|
namespace App\Http\Resources\PublicApi;
|
||||||
|
|
||||||
use App\Models\Page;
|
use App\Models\Page;
|
||||||
|
use App\Models\Tag;
|
||||||
use App\Support\ImagePayload;
|
use App\Support\ImagePayload;
|
||||||
use App\Support\SiteUrls;
|
use App\Support\SiteUrls;
|
||||||
use App\Support\TranslatablePayload;
|
use App\Support\TranslatablePayload;
|
||||||
@@ -58,6 +59,29 @@ class PublicPageResource extends JsonResource
|
|||||||
|
|
||||||
'category' => PublicCategoryResource::make($this->whenLoaded('category')),
|
'category' => PublicCategoryResource::make($this->whenLoaded('category')),
|
||||||
|
|
||||||
|
// Only the tags named in this language: one without a slug here
|
||||||
|
// has no listing to link to.
|
||||||
|
'tags' => $this->whenLoaded('tags', fn () => $this->tags
|
||||||
|
->filter(fn (Tag $tag) => filled($tag->getTranslation('slug', $locale, false)))
|
||||||
|
->map(fn (Tag $tag) => [
|
||||||
|
'name' => $tag->getTranslation('name', $locale),
|
||||||
|
'slug' => $tag->getTranslation('slug', $locale, false),
|
||||||
|
])
|
||||||
|
->values()),
|
||||||
|
|
||||||
|
// Who signs it — only someone with a public profile to link to.
|
||||||
|
'byline' => $this->whenLoaded('byline', fn () => $this->byline?->is_public ? [
|
||||||
|
'name' => $this->byline->name,
|
||||||
|
'slug' => $this->byline->slug,
|
||||||
|
'headline' => $this->byline->getTranslation('headline', $locale),
|
||||||
|
'avatar' => ImagePayload::make(
|
||||||
|
$this->byline->avatar,
|
||||||
|
$this->byline->relationLoaded('avatarMedia') ? $this->byline->getRelation('avatarMedia') : null,
|
||||||
|
$this->byline->name,
|
||||||
|
),
|
||||||
|
'path' => SiteUrls::path('member', $locale, $this->byline->slug),
|
||||||
|
] : null),
|
||||||
|
|
||||||
// Lets the frontend emit correct hreflang tags. Only locales that
|
// Lets the frontend emit correct hreflang tags. Only locales that
|
||||||
// actually have a translation appear.
|
// actually have a translation appear.
|
||||||
'alternates' => SiteUrls::alternates($this->resource, 'page'),
|
'alternates' => SiteUrls::alternates($this->resource, 'page'),
|
||||||
|
|||||||
35
app/Http/Resources/RedirectResource.php
Normal file
35
app/Http/Resources/RedirectResource.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Redirect;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Redirect
|
||||||
|
*/
|
||||||
|
class RedirectResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'from' => $this->from,
|
||||||
|
'to' => $this->to,
|
||||||
|
'status' => $this->status,
|
||||||
|
'locale' => $this->locale,
|
||||||
|
'is_enabled' => (bool) $this->is_enabled,
|
||||||
|
'automatic' => $this->resource->wasAutomatic(),
|
||||||
|
'hits' => (int) $this->hits,
|
||||||
|
'last_used_at' => $this->last_used_at?->toIso8601String(),
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Http/Resources/RepositoryResource.php
Normal file
51
app/Http/Resources/RepositoryResource.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Repository;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Repository
|
||||||
|
*/
|
||||||
|
class RepositoryResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'provider' => $this->provider->value,
|
||||||
|
'full_name' => $this->full_name,
|
||||||
|
'project_id' => $this->project_id,
|
||||||
|
'is_primary' => (bool) $this->is_primary,
|
||||||
|
'show_publicly' => (bool) $this->show_publicly,
|
||||||
|
|
||||||
|
// What the provider last reported.
|
||||||
|
'html_url' => $this->html_url,
|
||||||
|
'description' => $this->description,
|
||||||
|
'homepage' => $this->homepage,
|
||||||
|
'default_branch' => $this->default_branch,
|
||||||
|
'primary_language' => $this->primary_language,
|
||||||
|
'license' => $this->license,
|
||||||
|
'topics' => $this->topics ?? [],
|
||||||
|
'stars' => (int) $this->stars,
|
||||||
|
'forks' => (int) $this->forks,
|
||||||
|
'open_issues_count' => (int) $this->open_issues_count,
|
||||||
|
'is_archived' => (bool) $this->is_archived,
|
||||||
|
'pushed_at' => $this->pushed_at?->toIso8601String(),
|
||||||
|
|
||||||
|
'last_synced_at' => $this->last_synced_at?->toIso8601String(),
|
||||||
|
'sync_error' => $this->sync_error,
|
||||||
|
'sync_failures' => (int) $this->sync_failures,
|
||||||
|
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Http/Resources/ReviewCommentResource.php
Normal file
32
app/Http/Resources/ReviewCommentResource.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\ReviewComment;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin ReviewComment
|
||||||
|
*/
|
||||||
|
class ReviewCommentResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'event' => $this->event->value,
|
||||||
|
'body' => $this->body,
|
||||||
|
'author' => [
|
||||||
|
'id' => $this->user_id,
|
||||||
|
'name' => $this->user?->name ?? $this->user_name,
|
||||||
|
],
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Http/Resources/RevisionResource.php
Normal file
33
app/Http/Resources/RevisionResource.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Revision;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Revision
|
||||||
|
*/
|
||||||
|
class RevisionResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'event' => $this->event,
|
||||||
|
'author' => [
|
||||||
|
'id' => $this->user_id,
|
||||||
|
'name' => $this->resource->author(),
|
||||||
|
],
|
||||||
|
// Field => {old, new}. Translatable fields carry every language.
|
||||||
|
'changes' => (object) ($this->changeset ?? []),
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user