Compare commits
15 Commits
4c4a2b3299
...
9f970808ca
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f970808ca | |||
| 6fc600e6a1 | |||
| 76d022053b | |||
| 697d333ad4 | |||
| 8d0f81f9c5 | |||
| 773d3901c6 | |||
| 366568b793 | |||
| 70521d4bc9 | |||
| 1fe1e67773 | |||
| 9bdbf834f6 | |||
| f93775e549 | |||
| 0119004f2c | |||
| 6b3517743a | |||
| 68c5bd4274 | |||
| 5b46436641 |
240
README.md
240
README.md
@@ -663,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.
|
||||||
@@ -941,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
|
||||||
@@ -1156,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.
|
||||||
|
|
||||||
@@ -1302,6 +1383,39 @@ salen desactivados): sin `projects.publish` o `services.publish`, la API rechaza
|
|||||||
algo no puede sacarlo a la web solo. Las relaciones enviadas sustituyen a las
|
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.
|
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,
|
Los ajustes siguen los permisos del panel: `settings.view` para leer,
|
||||||
`settings.update` para cambiar y `settings.maintenance` para `site.maintenance`
|
`settings.update` para cambiar y `settings.maintenance` para `site.maintenance`
|
||||||
y `/v1/maintenance`. `content.version` lo escribe la aplicación y la API lo
|
y `/v1/maintenance`. `content.version` lo escribe la aplicación y la API lo
|
||||||
@@ -1315,6 +1429,121 @@ 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
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -1484,7 +1713,9 @@ variables, porque quien lo lanza es quien inicia sesión:
|
|||||||
LANDING_URL raíz de la aplicación (por defecto http://localhost:8000)
|
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_USERNAME credenciales de Pireon para la API de administración;
|
||||||
LANDING_PASSWORD el token se pide y se refresca solo
|
LANDING_PASSWORD el token se pide y se refresca solo
|
||||||
LANDING_TOKEN o un bearer token fijo en vez de credenciales
|
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_PREVIEW_TOKEN opcional: las herramientas públicas ven borradores
|
||||||
LANDING_OPENAPI opcional: ruta o URL de la especificación
|
LANDING_OPENAPI opcional: ruta o URL de la especificación
|
||||||
```
|
```
|
||||||
@@ -1529,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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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\Filament\Support\TranslatedOptionLabel;
|
||||||
@@ -20,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;
|
||||||
@@ -230,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(),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Filament\Resources\Pages\PageResource;
|
|||||||
use App\Filament\Support\TranslatableTabs;
|
use App\Filament\Support\TranslatableTabs;
|
||||||
use App\Filament\Support\TranslatedOptionLabel;
|
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;
|
||||||
@@ -126,6 +127,39 @@ class PageForm
|
|||||||
->searchable()
|
->searchable()
|
||||||
->preload(),
|
->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()
|
||||||
|
->preload(),
|
||||||
|
|
||||||
Select::make('author_id')
|
Select::make('author_id')
|
||||||
->label(__('panel.pages.fields.author'))
|
->label(__('panel.pages.fields.author'))
|
||||||
->relationship('author', 'name')
|
->relationship('author', 'name')
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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;
|
||||||
@@ -20,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;
|
||||||
@@ -211,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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('/'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
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()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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', 'featuredMedia'])
|
->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', 'featuredMedia']))
|
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', 'featuredMedia']));
|
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', 'featuredMedia']));
|
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', 'featuredMedia']));
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)],
|
||||||
|
|||||||
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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
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,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.
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Resources;
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\Permissions;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
@@ -35,7 +36,10 @@ class UserResource extends JsonResource
|
|||||||
'roles' => $this->whenLoaded('roles', fn () => $this->roles->pluck('name')->values()),
|
'roles' => $this->whenLoaded('roles', fn () => $this->roles->pluck('name')->values()),
|
||||||
'permissions' => $this->when(
|
'permissions' => $this->when(
|
||||||
$request->routeIs('api.v1.me'),
|
$request->routeIs('api.v1.me'),
|
||||||
fn () => $this->getAllPermissions()->pluck('name')->values(),
|
// Through a machine token, only what the token may do.
|
||||||
|
fn () => $this->resource->currentAccessToken()
|
||||||
|
? collect(Permissions::all())->filter(fn (string $permission) => $this->resource->can($permission))->values()
|
||||||
|
: $this->getAllPermissions()->pluck('name')->values(),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Roles reported by the identity server. Informational only: they
|
// Roles reported by the identity server. Informational only: they
|
||||||
|
|||||||
34
app/Http/Resources/WebhookDeliveryResource.php
Normal file
34
app/Http/Resources/WebhookDeliveryResource.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\WebhookDelivery;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin WebhookDelivery
|
||||||
|
*/
|
||||||
|
class WebhookDeliveryResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'uuid' => $this->uuid,
|
||||||
|
'event' => $this->event,
|
||||||
|
'status' => $this->status,
|
||||||
|
'attempts' => (int) $this->attempts,
|
||||||
|
'response_status' => $this->response_status,
|
||||||
|
'error' => $this->error,
|
||||||
|
'payload' => (object) ($this->payload ?? []),
|
||||||
|
'delivered_at' => $this->delivered_at?->toIso8601String(),
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Http/Resources/WebhookResource.php
Normal file
36
app/Http/Resources/WebhookResource.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Webhook;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Never the secret.
|
||||||
|
*
|
||||||
|
* @mixin Webhook
|
||||||
|
*/
|
||||||
|
class WebhookResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'url' => $this->url,
|
||||||
|
'events' => $this->events ?? [],
|
||||||
|
'include_personal_data' => (bool) $this->include_personal_data,
|
||||||
|
'is_active' => (bool) $this->is_active,
|
||||||
|
'consecutive_failures' => (int) $this->consecutive_failures,
|
||||||
|
'last_delivered_at' => $this->last_delivered_at?->toIso8601String(),
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
152
app/Jobs/DeliverWebhook.php
Normal file
152
app/Jobs/DeliverWebhook.php
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Webhook;
|
||||||
|
use App\Models\WebhookDelivery;
|
||||||
|
use App\Support\Webhooks\PublicEndpoint;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends one delivery, signed, and retries it with growing pauses.
|
||||||
|
*
|
||||||
|
* The receiver checks `X-Webhook-Signature`: `sha256=` and the HMAC of
|
||||||
|
* `{timestamp}.{body}` with the webhook's secret, the timestamp being
|
||||||
|
* `X-Webhook-Timestamp` — so a captured delivery cannot be replayed later
|
||||||
|
* under a new date.
|
||||||
|
*/
|
||||||
|
class DeliverWebhook implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public int $tries = 5;
|
||||||
|
|
||||||
|
public function __construct(public WebhookDelivery $delivery) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
public function backoff(): array
|
||||||
|
{
|
||||||
|
return [60, 300, 1800, 7200];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(PublicEndpoint $endpoint): void
|
||||||
|
{
|
||||||
|
$delivery = $this->delivery;
|
||||||
|
$webhook = $delivery->webhook;
|
||||||
|
|
||||||
|
if ($webhook === null || ! $webhook->is_active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$delivery->increment('attempts');
|
||||||
|
|
||||||
|
$body = (string) json_encode([
|
||||||
|
'id' => $delivery->uuid,
|
||||||
|
'event' => $delivery->event,
|
||||||
|
'created_at' => $delivery->created_at?->toIso8601String(),
|
||||||
|
'data' => $delivery->payload,
|
||||||
|
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
[$address, $refused] = $endpoint->check($webhook->url);
|
||||||
|
|
||||||
|
// Checked again at every attempt, not only when the URL was saved: a
|
||||||
|
// name can be pointed somewhere else in between.
|
||||||
|
if ($refused !== null) {
|
||||||
|
$this->failed(new RuntimeException(__("rules.media.remote.{$refused}")));
|
||||||
|
$this->delete();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = parse_url($webhook->url);
|
||||||
|
$timestamp = (string) now()->timestamp;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::withOptions([
|
||||||
|
'allow_redirects' => false,
|
||||||
|
'connect_timeout' => 5,
|
||||||
|
'timeout' => 10,
|
||||||
|
'curl' => [CURLOPT_RESOLVE => [sprintf(
|
||||||
|
'%s:%d:%s',
|
||||||
|
trim((string) $parts['host'], '[]'),
|
||||||
|
(int) ($parts['port'] ?? 443),
|
||||||
|
str_contains((string) $address, ':') ? "[{$address}]" : $address,
|
||||||
|
)]],
|
||||||
|
])
|
||||||
|
->withHeaders([
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'X-Webhook-Event' => $delivery->event,
|
||||||
|
'X-Webhook-Delivery' => $delivery->uuid,
|
||||||
|
'X-Webhook-Timestamp' => $timestamp,
|
||||||
|
'X-Webhook-Signature' => 'sha256='.hash_hmac('sha256', "{$timestamp}.{$body}", (string) $webhook->secret),
|
||||||
|
])
|
||||||
|
->withUserAgent(sprintf('%s/1.0 (+%s; webhooks)', Str::slug((string) config('app.name')) ?: 'landing', rtrim((string) config('app.url'), '/')))
|
||||||
|
->withBody($body, 'application/json')
|
||||||
|
->post($webhook->url);
|
||||||
|
} catch (ConnectionException $e) {
|
||||||
|
$this->retryOrFail($e->getMessage(), null);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
$delivery->update([
|
||||||
|
'status' => WebhookDelivery::SUCCEEDED,
|
||||||
|
'response_status' => $response->status(),
|
||||||
|
'error' => null,
|
||||||
|
'delivered_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$webhook->forceFill(['consecutive_failures' => 0, 'last_delivered_at' => now()])->save();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->retryOrFail(Str::limit($response->body(), 500), $response->status());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function failed(?\Throwable $e): void
|
||||||
|
{
|
||||||
|
$this->delivery->update([
|
||||||
|
'status' => WebhookDelivery::FAILED,
|
||||||
|
'error' => Str::limit((string) $e?->getMessage(), 500),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$webhook = $this->delivery->webhook;
|
||||||
|
|
||||||
|
if ($webhook !== null) {
|
||||||
|
$failures = $webhook->consecutive_failures + 1;
|
||||||
|
|
||||||
|
$webhook->forceFill([
|
||||||
|
'consecutive_failures' => $failures,
|
||||||
|
'is_active' => $failures < Webhook::MAX_FAILURES && $webhook->is_active,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function retryOrFail(string $error, ?int $status): void
|
||||||
|
{
|
||||||
|
$this->delivery->update(['response_status' => $status, 'error' => $error]);
|
||||||
|
|
||||||
|
if ($this->attempts() < $this->tries) {
|
||||||
|
$this->release($this->backoff()[$this->attempts() - 1] ?? 7200);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->failed(new RuntimeException($error));
|
||||||
|
$this->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Mail/ApplicationReplyMail.php
Normal file
39
app/Mail/ApplicationReplyMail.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use App\Models\ApplicationReply;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Address;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The answer, as plain text — what a person would have typed, not a
|
||||||
|
* newsletter.
|
||||||
|
*/
|
||||||
|
class ApplicationReplyMail extends Mailable
|
||||||
|
{
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(public ApplicationReply $reply) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
$replyTo = config('mail.reply_to.address');
|
||||||
|
|
||||||
|
return new Envelope(
|
||||||
|
subject: $this->reply->subject,
|
||||||
|
replyTo: $replyTo ? [new Address((string) $replyTo, (string) config('mail.reply_to.name'))] : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(text: 'mail.application-reply');
|
||||||
|
}
|
||||||
|
}
|
||||||
128
app/Models/ApiToken.php
Normal file
128
app/Models/ApiToken.php
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An access token for a machine.
|
||||||
|
*
|
||||||
|
* It acts as the person who made it, and never as more: what it may do is the
|
||||||
|
* intersection of its `abilities` and that person's own permissions, checked
|
||||||
|
* on every request — so taking a role away from them narrows their tokens at
|
||||||
|
* once. It always expires, and only its hash is stored.
|
||||||
|
*/
|
||||||
|
#[Fillable(['name', 'abilities', 'expires_at'])]
|
||||||
|
class ApiToken extends Model
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* What tells this application's tokens from the identity server's, so a
|
||||||
|
* machine token is never sent upstream to be checked.
|
||||||
|
*/
|
||||||
|
public const PREFIX = 'pmt_';
|
||||||
|
|
||||||
|
public const MAX_DAYS = 365;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'abilities' => 'array',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'last_used_at' => 'datetime',
|
||||||
|
'revoked_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<User, $this>
|
||||||
|
*/
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make one, and return the plain token alongside: the only time it exists
|
||||||
|
* anywhere but in the caller's hands.
|
||||||
|
*
|
||||||
|
* @param array<int, string> $abilities
|
||||||
|
* @return array{0: self, 1: string}
|
||||||
|
*/
|
||||||
|
public static function issue(User $owner, string $name, array $abilities, CarbonInterface $expiresAt): array
|
||||||
|
{
|
||||||
|
$plain = self::PREFIX.Str::random(40);
|
||||||
|
|
||||||
|
$token = new self(['name' => $name, 'abilities' => array_values($abilities), 'expires_at' => $expiresAt]);
|
||||||
|
$token->user()->associate($owner);
|
||||||
|
$token->forceFill([
|
||||||
|
'token_hash' => self::hash($plain),
|
||||||
|
'prefix' => substr($plain, 0, 12),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return [$token, $plain];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function hash(string $plain): string
|
||||||
|
{
|
||||||
|
return hash('sha256', $plain);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function findUsable(string $plain): ?self
|
||||||
|
{
|
||||||
|
$token = self::query()->with('user')->where('token_hash', self::hash($plain))->first();
|
||||||
|
|
||||||
|
return $token?->isUsable() ? $token : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isUsable(): bool
|
||||||
|
{
|
||||||
|
return $this->revoked_at === null && $this->expires_at->isFuture();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the token was given this permission. `pages.*` covers every
|
||||||
|
* permission on pages.
|
||||||
|
*/
|
||||||
|
public function allows(string $permission): bool
|
||||||
|
{
|
||||||
|
foreach ($this->abilities ?? [] as $ability) {
|
||||||
|
if ($ability === $permission || Str::is($ability, $permission)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recorded at most once a minute: a busy script would otherwise turn
|
||||||
|
* every request into a write.
|
||||||
|
*/
|
||||||
|
public function touchLastUsed(): void
|
||||||
|
{
|
||||||
|
if ($this->last_used_at === null || $this->last_used_at->lt(now()->subMinute())) {
|
||||||
|
$this->forceFill(['last_used_at' => now()])->saveQuietly();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revoke(): void
|
||||||
|
{
|
||||||
|
$this->forceFill(['revoked_at' => now()])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Builder<$this> $query
|
||||||
|
* @return Builder<$this>
|
||||||
|
*/
|
||||||
|
public function scopeUsable(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereNull('revoked_at')->where('expires_at', '>', now());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Builder;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Support\Facades\Config;
|
use Illuminate\Support\Facades\Config;
|
||||||
|
|
||||||
@@ -59,6 +60,14 @@ class Application extends Model
|
|||||||
/**
|
/**
|
||||||
* @return BelongsTo<User, $this>
|
* @return BelongsTo<User, $this>
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* @return HasMany<ApplicationReply, $this>
|
||||||
|
*/
|
||||||
|
public function replies(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ApplicationReply::class)->latest('id');
|
||||||
|
}
|
||||||
|
|
||||||
public function handler(): BelongsTo
|
public function handler(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'handled_by');
|
return $this->belongsTo(User::class, 'handled_by');
|
||||||
|
|||||||
31
app/Models/ApplicationReply.php
Normal file
31
app/Models/ApplicationReply.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An answer given to a submission, kept so the next person to open it knows
|
||||||
|
* what was already said.
|
||||||
|
*/
|
||||||
|
#[Fillable(['user_id', 'user_name', 'subject', 'body', 'channel'])]
|
||||||
|
class ApplicationReply extends Model
|
||||||
|
{
|
||||||
|
public const UPDATED_AT = null;
|
||||||
|
|
||||||
|
public const MAIL = 'mail';
|
||||||
|
|
||||||
|
public const MAILTO = 'mailto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Application, $this>
|
||||||
|
*/
|
||||||
|
public function application(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Application::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Models/AuditLog.php
Normal file
46
app/Models/AuditLog.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\MassPrunable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One thing somebody did. Written by App\Support\Audit\Auditor, never edited.
|
||||||
|
*/
|
||||||
|
class AuditLog extends Model
|
||||||
|
{
|
||||||
|
use MassPrunable;
|
||||||
|
|
||||||
|
public const UPDATED_AT = null;
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'changes' => 'array',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<User, $this>
|
||||||
|
*/
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Builder<$this>
|
||||||
|
*/
|
||||||
|
public function prunable(): Builder
|
||||||
|
{
|
||||||
|
return static::query()->where('created_at', '<', now()->subDays((int) config('audit.retention_days', 365)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Models\Concerns;
|
namespace App\Models\Concerns;
|
||||||
|
|
||||||
use App\Models\Revision;
|
use App\Models\Revision;
|
||||||
|
use App\Support\AdminContent;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -119,17 +120,34 @@ trait HasRevisions
|
|||||||
*/
|
*/
|
||||||
protected function revisionEvent(array $changes): string
|
protected function revisionEvent(array $changes): string
|
||||||
{
|
{
|
||||||
if (! array_key_exists('published_at', $changes) && ! array_key_exists('status', $changes)) {
|
// Which fields put this kind of record on the site: a project's
|
||||||
|
// `status` is whether the work is active, not whether it is public,
|
||||||
|
// and a service is published by a flag.
|
||||||
|
$publication = AdminContent::publicationFields($this) ?: ['published_at', 'status'];
|
||||||
|
|
||||||
|
if (array_intersect(array_keys($changes), $publication) === []) {
|
||||||
return 'updated';
|
return 'updated';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ask the model itself: "published" means different things to a page
|
// Ask the model itself: "published" means different things to a page
|
||||||
// with a status and a project with only a date.
|
// with a status and a project with only a date.
|
||||||
if (method_exists($this, 'isPublishedNow')) {
|
if (! method_exists($this, 'isPublishedNow')) {
|
||||||
return $this->isPublishedNow() ? 'published' : 'unpublished';
|
return 'updated';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'updated';
|
// Before and after, because the change is what has a name: going live,
|
||||||
|
// coming off, or being given a date — which is neither, and which a
|
||||||
|
// history that only asked "is it live now?" called "unpublished".
|
||||||
|
$before = (clone $this)->setRawAttributes($this->getRawOriginal());
|
||||||
|
$now = $this->isPublishedNow();
|
||||||
|
$was = $before->isPublishedNow();
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$now && ! $was => 'published',
|
||||||
|
! $now && $was => 'unpublished',
|
||||||
|
! $now && method_exists($this, 'isScheduled') && $this->isScheduled() => 'scheduled',
|
||||||
|
default => 'updated',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function revisionValue(mixed $value): mixed
|
protected function revisionValue(mixed $value): mixed
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\Builder;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Spatie\Translatable\HasTranslations;
|
use Spatie\Translatable\HasTranslations;
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ use Spatie\Translatable\HasTranslations;
|
|||||||
* @property array<string, string> $slug
|
* @property array<string, string> $slug
|
||||||
*/
|
*/
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'category_id', 'author_id', 'title', 'slug', 'excerpt', 'body',
|
'category_id', 'author_id', 'byline_member_id', 'title', 'slug', 'excerpt', 'body',
|
||||||
'meta_title', 'meta_description', 'status', 'featured_image',
|
'meta_title', 'meta_description', 'status', 'featured_image',
|
||||||
'featured_image_alt', 'is_featured', 'published_at', 'unpublish_at',
|
'featured_image_alt', 'is_featured', 'published_at', 'unpublish_at',
|
||||||
])]
|
])]
|
||||||
@@ -146,6 +147,25 @@ class Page extends Model
|
|||||||
return $this->belongsTo(User::class, 'author_id');
|
return $this->belongsTo(User::class, 'author_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who signs it on the site: a member, with a profile to link to. Not the
|
||||||
|
* account that typed it in, which is `author` and stays in the panel.
|
||||||
|
*
|
||||||
|
* @return BelongsTo<Member, $this>
|
||||||
|
*/
|
||||||
|
public function byline(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Member::class, 'byline_member_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsToMany<Tag, $this>
|
||||||
|
*/
|
||||||
|
public function tags(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Tag::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publicly visible pages: published and not scheduled for the future.
|
* Publicly visible pages: published and not scheduled for the future.
|
||||||
*
|
*
|
||||||
|
|||||||
64
app/Models/ReplyTemplate.php
Normal file
64
app/Models/ReplyTemplate.php
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\ApplicationKind;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Spatie\Translatable\HasTranslations;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An answer the inbox gives often enough to write once: "thanks, we'll be in
|
||||||
|
* touch", "this call is filled", "here is how we work".
|
||||||
|
*
|
||||||
|
* Written in every language, because the answer goes out in the one the
|
||||||
|
* person wrote in. `{name}`, `{subject}` and `{sender}` are filled in.
|
||||||
|
*/
|
||||||
|
#[Fillable(['name', 'kind', 'subject', 'body', 'position'])]
|
||||||
|
class ReplyTemplate extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, HasTranslations;
|
||||||
|
|
||||||
|
public const PLACEHOLDERS = ['{name}', '{subject}', '{sender}'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
public array $translatable = ['subject', 'body'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['position' => 'integer'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Builder<$this> $query
|
||||||
|
* @return Builder<$this>
|
||||||
|
*/
|
||||||
|
public function scopeFor(Builder $query, ApplicationKind $kind): Builder
|
||||||
|
{
|
||||||
|
return $query
|
||||||
|
->where(fn (Builder $q) => $q->whereNull('kind')->orWhere('kind', $kind->value))
|
||||||
|
->orderBy('position')
|
||||||
|
->orderBy('name');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subject and body for one submission, in its language.
|
||||||
|
*
|
||||||
|
* @return array{subject: string, body: string}
|
||||||
|
*/
|
||||||
|
public function renderFor(Application $application, ?User $sender = null): array
|
||||||
|
{
|
||||||
|
$values = [$application->name, $application->subject(), $sender?->name ?? (string) config('app.name')];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'subject' => str_replace(self::PLACEHOLDERS, $values, (string) $this->getTranslation('subject', $application->locale)),
|
||||||
|
'body' => str_replace(self::PLACEHOLDERS, $values, (string) $this->getTranslation('body', $application->locale)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Models/Tag.php
Normal file
37
app/Models/Tag.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BumpsContentVersion;
|
||||||
|
use Database\Factories\TagFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Spatie\Translatable\HasTranslations;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a post is about — `angular`, `accesibilidad` — so the blog can be read
|
||||||
|
* by subject. Named in every language, like a category.
|
||||||
|
*/
|
||||||
|
#[Fillable(['name', 'slug'])]
|
||||||
|
class Tag extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<TagFactory> */
|
||||||
|
use BumpsContentVersion, HasFactory, HasTranslations;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
public array $translatable = ['name', 'slug'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsToMany<Page, $this>
|
||||||
|
*/
|
||||||
|
public function pages(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Page::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,32 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasName
|
|||||||
*/
|
*/
|
||||||
protected string $guard_name = 'web';
|
protected string $guard_name = 'web';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The machine token this request came in with, when it did. Not stored:
|
||||||
|
* it describes the request, not the person.
|
||||||
|
*/
|
||||||
|
protected ?ApiToken $accessToken = null;
|
||||||
|
|
||||||
|
public function withAccessToken(?ApiToken $token): static
|
||||||
|
{
|
||||||
|
$this->accessToken = $token;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentAccessToken(): ?ApiToken
|
||||||
|
{
|
||||||
|
return $this->accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return HasMany<ApiToken, $this>
|
||||||
|
*/
|
||||||
|
public function apiTokens(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ApiToken::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the attributes that should be cast.
|
* Get the attributes that should be cast.
|
||||||
*
|
*
|
||||||
|
|||||||
80
app/Models/Webhook.php
Normal file
80
app/Models/Webhook.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Somewhere to tell when something happens here.
|
||||||
|
*/
|
||||||
|
#[Fillable(['name', 'url', 'secret', 'events', 'include_personal_data', 'is_active'])]
|
||||||
|
#[Hidden(['secret'])]
|
||||||
|
class Webhook extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What can be subscribed to. `*` is all of them.
|
||||||
|
*/
|
||||||
|
public const EVENTS = [
|
||||||
|
'application.received',
|
||||||
|
'content.published',
|
||||||
|
'content.unpublished',
|
||||||
|
'review.requested',
|
||||||
|
'review.returned',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switched off after this many failed deliveries in a row, so a dead
|
||||||
|
* endpoint does not keep a queue busy forever. Switching it back on is a
|
||||||
|
* person's decision.
|
||||||
|
*/
|
||||||
|
public const MAX_FAILURES = 20;
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'include_personal_data' => false,
|
||||||
|
'is_active' => true,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'secret' => 'encrypted',
|
||||||
|
'events' => 'array',
|
||||||
|
'include_personal_data' => 'boolean',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'last_delivered_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return HasMany<WebhookDelivery, $this>
|
||||||
|
*/
|
||||||
|
public function deliveries(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(WebhookDelivery::class)->latest('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listensTo(string $event): bool
|
||||||
|
{
|
||||||
|
$events = $this->events ?? [];
|
||||||
|
|
||||||
|
return in_array('*', $events, true) || in_array($event, $events, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Builder<$this> $query
|
||||||
|
* @return Builder<$this>
|
||||||
|
*/
|
||||||
|
public function scopeActive(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('is_active', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Models/WebhookDelivery.php
Normal file
40
app/Models/WebhookDelivery.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One attempt to tell a webhook about one event, and how it went.
|
||||||
|
*/
|
||||||
|
#[Fillable(['uuid', 'event', 'payload', 'status', 'attempts', 'response_status', 'error', 'delivered_at'])]
|
||||||
|
class WebhookDelivery extends Model
|
||||||
|
{
|
||||||
|
public const PENDING = 'pending';
|
||||||
|
|
||||||
|
public const SUCCEEDED = 'succeeded';
|
||||||
|
|
||||||
|
public const FAILED = 'failed';
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'payload' => 'array',
|
||||||
|
'attempts' => 'integer',
|
||||||
|
'response_status' => 'integer',
|
||||||
|
'delivered_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Webhook, $this>
|
||||||
|
*/
|
||||||
|
public function webhook(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Webhook::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Notifications/ExpiringSoon.php
Normal file
50
app/Notifications/ExpiringSoon.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Notifications\Notification as FilamentNotification;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A digest: what this person looks after that is about to close or come off
|
||||||
|
* the site.
|
||||||
|
*/
|
||||||
|
class ExpiringSoon extends Notification
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Collection<int, array<string, mixed>> $items
|
||||||
|
*/
|
||||||
|
public function __construct(public readonly Collection $items) {}
|
||||||
|
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toDatabase(object $notifiable): array
|
||||||
|
{
|
||||||
|
$first = $this->items->first();
|
||||||
|
|
||||||
|
return FilamentNotification::make()
|
||||||
|
->title(trans_choice('panel.expiring.notification.title', $this->items->count(), ['count' => $this->items->count()]))
|
||||||
|
->body($this->items
|
||||||
|
->map(fn (array $item) => $item['title'].' — '.__('panel.expiring.events.'.$item['event'], [
|
||||||
|
'when' => $item['at']->diffForHumans(),
|
||||||
|
'date' => $item['at']->isoFormat('LL'),
|
||||||
|
]))
|
||||||
|
->implode("\n"))
|
||||||
|
->icon('heroicon-o-clock')
|
||||||
|
->iconColor('warning')
|
||||||
|
->actions($this->items->count() === 1 ? [
|
||||||
|
Action::make('open')
|
||||||
|
->label(__('panel.expiring.notification.open'))
|
||||||
|
->url($first['url'])
|
||||||
|
->markAsRead(),
|
||||||
|
] : [])
|
||||||
|
->getDatabaseMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Policies/ReplyTemplatePolicy.php
Normal file
44
app/Policies/ReplyTemplatePolicy.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Templates belong to the inbox: whoever answers it may write them.
|
||||||
|
*/
|
||||||
|
class ReplyTemplatePolicy
|
||||||
|
{
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->can('applications.view_any');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, Model $model): bool
|
||||||
|
{
|
||||||
|
return $user->can('applications.view_any');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->can('applications.update');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, Model $model): bool
|
||||||
|
{
|
||||||
|
return $user->can('applications.update');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, Model $model): bool
|
||||||
|
{
|
||||||
|
return $user->can('applications.update');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->can('applications.update');
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Policies/TagPolicy.php
Normal file
17
app/Policies/TagPolicy.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tags are kept by whoever keeps the categories: the same job, sorting the
|
||||||
|
* blog, with no separate permission nobody would remember to grant.
|
||||||
|
*/
|
||||||
|
class TagPolicy extends ResourcePolicy
|
||||||
|
{
|
||||||
|
protected function resource(): string
|
||||||
|
{
|
||||||
|
return 'categories';
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/Policies/WebhookPolicy.php
Normal file
13
app/Policies/WebhookPolicy.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
class WebhookPolicy extends ResourcePolicy
|
||||||
|
{
|
||||||
|
protected function resource(): string
|
||||||
|
{
|
||||||
|
return 'webhooks';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,16 @@ declare(strict_types=1);
|
|||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Auth\PireonTokenGuard;
|
use App\Auth\PireonTokenGuard;
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\ReviewComment;
|
||||||
|
use App\Models\Revision;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\Git\GitManager;
|
use App\Services\Git\GitManager;
|
||||||
use App\Services\Pireon\PireonClient;
|
use App\Services\Pireon\PireonClient;
|
||||||
use App\Services\Pireon\TokenStore;
|
use App\Services\Pireon\TokenStore;
|
||||||
|
use App\Support\Audit\Auditor;
|
||||||
|
use App\Support\Webhooks\Webhooks;
|
||||||
|
use Illuminate\Auth\Events\Login;
|
||||||
use Illuminate\Auth\Events\Logout;
|
use Illuminate\Auth\Events\Logout;
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -18,6 +25,9 @@ use Illuminate\Support\Facades\RateLimiter;
|
|||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Spatie\Permission\Events\RoleAttached;
|
||||||
|
use Spatie\Permission\Events\RoleDetached;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -34,8 +44,45 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
$this->app->singleton(GitManager::class);
|
$this->app->singleton(GitManager::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
protected static function roleNames(mixed $rolesOrIds): array
|
||||||
|
{
|
||||||
|
return Role::query()
|
||||||
|
->whereIn('id', collect(is_iterable($rolesOrIds) ? $rolesOrIds : [$rolesOrIds])
|
||||||
|
->map(fn ($role) => $role instanceof Role ? $role->id : $role)
|
||||||
|
->all())
|
||||||
|
->pluck('name')
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
// Outgoing webhooks, fed by the records themselves so the panel, the
|
||||||
|
// API and a batch all announce the same things the same way.
|
||||||
|
Application::created(fn (Application $application) => Webhooks::applicationReceived($application));
|
||||||
|
Revision::created(fn (Revision $revision) => Webhooks::revisionRecorded($revision));
|
||||||
|
ReviewComment::created(fn (ReviewComment $comment) => Webhooks::reviewRecorded($comment));
|
||||||
|
|
||||||
|
// The audit log: every change a person makes to an audited record,
|
||||||
|
// role changes, and signing in to the panel.
|
||||||
|
foreach (['created', 'updated', 'deleted', 'restored'] as $event) {
|
||||||
|
Event::listen("eloquent.{$event}: *", function (string $name, array $payload) use ($event): void {
|
||||||
|
if (($payload[0] ?? null) instanceof Model) {
|
||||||
|
Auditor::modelEvent($event, $payload[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Event::listen(RoleAttached::class, fn (RoleAttached $e) => Auditor::record('users.roles_added', $e->model, ['roles' => self::roleNames($e->rolesOrIds)]));
|
||||||
|
Event::listen(RoleDetached::class, fn (RoleDetached $e) => Auditor::record('users.roles_removed', $e->model, ['roles' => self::roleNames($e->rolesOrIds)]));
|
||||||
|
Event::listen(Login::class, function (Login $e): void {
|
||||||
|
if ($e->user instanceof User) {
|
||||||
|
Auditor::record('auth.login', $e->user, null, $e->user);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Backs the "api" guard declared in config/auth.php.
|
// Backs the "api" guard declared in config/auth.php.
|
||||||
Auth::viaRequest('pireon', fn (Request $request) => $this->app
|
Auth::viaRequest('pireon', fn (Request $request) => $this->app
|
||||||
->make(PireonTokenGuard::class)($request));
|
->make(PireonTokenGuard::class)($request));
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ use App\Models\MissingUrl;
|
|||||||
use App\Models\Page;
|
use App\Models\Page;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Redirect;
|
use App\Models\Redirect;
|
||||||
|
use App\Models\ReplyTemplate;
|
||||||
use App\Models\Repository;
|
use App\Models\Repository;
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
|
use App\Models\Tag;
|
||||||
use App\Models\Talk;
|
use App\Models\Talk;
|
||||||
use App\Models\Technology;
|
use App\Models\Technology;
|
||||||
use App\Models\Testimonial;
|
use App\Models\Testimonial;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Webhook;
|
||||||
use App\Policies\ApplicationPolicy;
|
use App\Policies\ApplicationPolicy;
|
||||||
use App\Policies\CallPolicy;
|
use App\Policies\CallPolicy;
|
||||||
use App\Policies\CategoryPolicy;
|
use App\Policies\CategoryPolicy;
|
||||||
@@ -30,13 +33,17 @@ use App\Policies\MissingUrlPolicy;
|
|||||||
use App\Policies\PagePolicy;
|
use App\Policies\PagePolicy;
|
||||||
use App\Policies\ProjectPolicy;
|
use App\Policies\ProjectPolicy;
|
||||||
use App\Policies\RedirectPolicy;
|
use App\Policies\RedirectPolicy;
|
||||||
|
use App\Policies\ReplyTemplatePolicy;
|
||||||
use App\Policies\RepositoryPolicy;
|
use App\Policies\RepositoryPolicy;
|
||||||
use App\Policies\RolePolicy;
|
use App\Policies\RolePolicy;
|
||||||
use App\Policies\ServicePolicy;
|
use App\Policies\ServicePolicy;
|
||||||
|
use App\Policies\TagPolicy;
|
||||||
use App\Policies\TalkPolicy;
|
use App\Policies\TalkPolicy;
|
||||||
use App\Policies\TechnologyPolicy;
|
use App\Policies\TechnologyPolicy;
|
||||||
use App\Policies\TestimonialPolicy;
|
use App\Policies\TestimonialPolicy;
|
||||||
use App\Policies\UserPolicy;
|
use App\Policies\UserPolicy;
|
||||||
|
use App\Policies\WebhookPolicy;
|
||||||
|
use App\Support\Permissions;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Spatie\Permission\Models\Role;
|
use Spatie\Permission\Models\Role;
|
||||||
@@ -62,13 +69,39 @@ class AuthServiceProvider extends ServiceProvider
|
|||||||
Gate::policy(Repository::class, RepositoryPolicy::class);
|
Gate::policy(Repository::class, RepositoryPolicy::class);
|
||||||
Gate::policy(User::class, UserPolicy::class);
|
Gate::policy(User::class, UserPolicy::class);
|
||||||
Gate::policy(Role::class, RolePolicy::class);
|
Gate::policy(Role::class, RolePolicy::class);
|
||||||
|
Gate::policy(Webhook::class, WebhookPolicy::class);
|
||||||
|
Gate::policy(ReplyTemplate::class, ReplyTemplatePolicy::class);
|
||||||
|
Gate::policy(Tag::class, TagPolicy::class);
|
||||||
|
|
||||||
// Super admins bypass every permission check. Granting the role this
|
// The one place a permission is granted — spatie's own gate check is
|
||||||
// way rather than by attaching every permission means the role can
|
// switched off (config/permission.php) so nothing answers before this.
|
||||||
// never fall behind as the catalogue grows.
|
|
||||||
//
|
//
|
||||||
// Returning null (not false) when the role is absent is important: it
|
// Super admins bypass every check. Granting the role this way rather
|
||||||
// lets the normal policy chain run instead of short-circuiting it.
|
// than by attaching every permission means the role can never fall
|
||||||
Gate::before(fn (User $user, string $ability) => $user->hasRole('super_admin') ? true : null);
|
// behind as the catalogue grows. Returning null (not false) when
|
||||||
|
// nothing applies lets the normal policy chain run.
|
||||||
|
//
|
||||||
|
// A machine token narrows all of that: a permission must be one the
|
||||||
|
// token was given *and* one its owner holds. A policy is not a
|
||||||
|
// permission, so it runs as usual and asks for permissions, which come
|
||||||
|
// back here.
|
||||||
|
Gate::before(function (User $user, string $ability): ?bool {
|
||||||
|
$token = $user->currentAccessToken();
|
||||||
|
|
||||||
|
if ($token !== null) {
|
||||||
|
if (! in_array($ability, Permissions::all(), true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $token->allows($ability)
|
||||||
|
&& ($user->hasRole('super_admin') || $user->checkPermissionTo($ability));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->hasRole('super_admin')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->checkPermissionTo($ability) ?: null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
132
app/Support/AdminContent.php
Normal file
132
app/Support/AdminContent.php
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\Call;
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\Talk;
|
||||||
|
use App\Models\Technology;
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The content the admin API addresses by name — `pages`, `projects` — and
|
||||||
|
* what each one supports beyond plain editing: a history, a review, a preview.
|
||||||
|
*
|
||||||
|
* One table rather than a flag on every model, so the routes, the
|
||||||
|
* specification and the controllers that serve those extras all read the same
|
||||||
|
* answer.
|
||||||
|
*/
|
||||||
|
final class AdminContent
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* API name => [model, route parameter].
|
||||||
|
*
|
||||||
|
* @var array<string, array{0: class-string<Model>, 1: string}>
|
||||||
|
*/
|
||||||
|
public const WITH_HISTORY = [
|
||||||
|
'pages' => [Page::class, 'page'],
|
||||||
|
'categories' => [Category::class, 'category'],
|
||||||
|
'projects' => [Project::class, 'project'],
|
||||||
|
'members' => [Member::class, 'member'],
|
||||||
|
'services' => [Service::class, 'service'],
|
||||||
|
'clients' => [Client::class, 'client'],
|
||||||
|
'testimonials' => [Testimonial::class, 'testimonial'],
|
||||||
|
'talks' => [Talk::class, 'talk'],
|
||||||
|
'technologies' => [Technology::class, 'technology'],
|
||||||
|
'calls' => [Call::class, 'call'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What goes through a review before it reaches the site, and the fields
|
||||||
|
* that put each one there — the ones `{resource}.publish` guards.
|
||||||
|
*
|
||||||
|
* @var array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
public const REVIEWABLE = [
|
||||||
|
'pages' => ['status', 'published_at', 'unpublish_at'],
|
||||||
|
'projects' => ['published_at', 'unpublish_at'],
|
||||||
|
'services' => ['is_published', 'published_at', 'unpublish_at'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What has an address on the public site to preview, by the type name
|
||||||
|
* `SiteUrls` knows it as.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
public const PREVIEWABLE = [
|
||||||
|
'pages' => 'page',
|
||||||
|
'projects' => 'project',
|
||||||
|
'services' => 'service',
|
||||||
|
'calls' => 'call',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return class-string<Model>
|
||||||
|
*/
|
||||||
|
public static function model(string $resource): string
|
||||||
|
{
|
||||||
|
return self::WITH_HISTORY[$resource][0] ?? abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function parameter(string $resource): string
|
||||||
|
{
|
||||||
|
return self::WITH_HISTORY[$resource][1] ?? abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The record a route names, binned ones included: their history is still
|
||||||
|
* worth reading, and restoring them is what the bin is for.
|
||||||
|
*/
|
||||||
|
public static function find(string $resource, mixed $id): Model
|
||||||
|
{
|
||||||
|
return self::findOf(self::model($resource), $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param class-string<Model> $model
|
||||||
|
*/
|
||||||
|
public static function findOf(string $model, mixed $id): Model
|
||||||
|
{
|
||||||
|
$query = $model::query();
|
||||||
|
|
||||||
|
if (in_array(SoftDeletes::class, class_uses_recursive($model), true)) {
|
||||||
|
$query->withTrashed();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->findOrFail($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The API name of a record, for a review queue that mixes several kinds.
|
||||||
|
*/
|
||||||
|
public static function nameOf(Model $record): ?string
|
||||||
|
{
|
||||||
|
foreach (self::WITH_HISTORY as $resource => [$model]) {
|
||||||
|
if ($record instanceof $model) {
|
||||||
|
return $resource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fields only `{resource}.publish` may write, for this record.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function publicationFields(Model $record): array
|
||||||
|
{
|
||||||
|
return self::REVIEWABLE[self::nameOf($record)] ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
234
app/Support/Audit/Auditor.php
Normal file
234
app/Support/Audit/Auditor.php
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Audit;
|
||||||
|
|
||||||
|
use App\Models\ApiToken;
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\Call;
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\Media;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Redirect;
|
||||||
|
use App\Models\Repository;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\Tag;
|
||||||
|
use App\Models\Talk;
|
||||||
|
use App\Models\Technology;
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Webhook;
|
||||||
|
use App\Support\AdminContent;
|
||||||
|
use App\Support\ContentVersion;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the audit log.
|
||||||
|
*
|
||||||
|
* Only what a person did: a repository's numbers refreshed by the sync job, or
|
||||||
|
* a 404 reported by the frontend, are the application at work, and logging
|
||||||
|
* them would bury the entries anybody comes here for.
|
||||||
|
*/
|
||||||
|
final class Auditor
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* What is audited, by the name the entries use for it.
|
||||||
|
*
|
||||||
|
* @var array<class-string<Model>, string>
|
||||||
|
*/
|
||||||
|
public const AUDITED = [
|
||||||
|
Page::class => 'pages',
|
||||||
|
Category::class => 'categories',
|
||||||
|
Tag::class => 'tags',
|
||||||
|
Project::class => 'projects',
|
||||||
|
Member::class => 'members',
|
||||||
|
Service::class => 'services',
|
||||||
|
Client::class => 'clients',
|
||||||
|
Testimonial::class => 'testimonials',
|
||||||
|
Talk::class => 'talks',
|
||||||
|
Technology::class => 'technologies',
|
||||||
|
Call::class => 'calls',
|
||||||
|
Application::class => 'applications',
|
||||||
|
Redirect::class => 'redirects',
|
||||||
|
Repository::class => 'repositories',
|
||||||
|
Media::class => 'media',
|
||||||
|
Setting::class => 'settings',
|
||||||
|
User::class => 'users',
|
||||||
|
ApiToken::class => 'tokens',
|
||||||
|
Webhook::class => 'webhooks',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Never written down, whatever changed.
|
||||||
|
*/
|
||||||
|
protected const SECRET = ['password', 'remember_token', 'token_hash', 'secret'];
|
||||||
|
|
||||||
|
protected const IGNORED = ['created_at', 'updated_at', 'last_used_at', 'last_login_at'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $changes
|
||||||
|
*/
|
||||||
|
public static function record(string $action, ?Model $subject = null, ?array $changes = null, ?User $actor = null): void
|
||||||
|
{
|
||||||
|
$actor ??= Auth::user();
|
||||||
|
|
||||||
|
if (! $actor instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = app()->bound('request') ? request() : null;
|
||||||
|
$route = $request?->route();
|
||||||
|
$token = $actor->currentAccessToken();
|
||||||
|
|
||||||
|
AuditLog::query()->create([
|
||||||
|
'user_id' => $actor->id,
|
||||||
|
'user_name' => $actor->name,
|
||||||
|
'via' => self::via($route?->getName(), $token !== null),
|
||||||
|
'api_token_id' => $token?->id,
|
||||||
|
'ip' => $route ? $request->ip() : null,
|
||||||
|
'action' => $action,
|
||||||
|
'subject_type' => $subject ? (self::AUDITED[$subject::class] ?? class_basename($subject)) : null,
|
||||||
|
'subject_id' => $subject ? (string) $subject->getKey() : null,
|
||||||
|
'subject_label' => $subject ? Str::limit(self::label($subject), 250) : null,
|
||||||
|
'changes' => $changes ?: null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* From a model event: `pages.created`, `pages.updated` with what changed,
|
||||||
|
* `pages.deleted`…
|
||||||
|
*/
|
||||||
|
public static function modelEvent(string $event, Model $model): void
|
||||||
|
{
|
||||||
|
$resource = self::AUDITED[$model::class] ?? null;
|
||||||
|
|
||||||
|
// Bumped by every content edit, by the application: the edit itself is
|
||||||
|
// what is logged.
|
||||||
|
if ($resource === null || ($model instanceof Setting && $model->getKey() === ContentVersion::KEY)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changes = match ($event) {
|
||||||
|
'created' => self::values($model->getAttributes()),
|
||||||
|
'updated' => self::diff($model),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Saved with nothing anyone would call a change.
|
||||||
|
if ($event === 'updated' && $changes === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::record("{$resource}.{$event}", $model, $changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function via(?string $routeName, bool $token): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
$token => 'token',
|
||||||
|
$routeName !== null && str_starts_with($routeName, 'api.') => 'api',
|
||||||
|
$routeName !== null && str_starts_with($routeName, 'filament.') => 'panel',
|
||||||
|
$routeName !== null, app()->runningInConsole() === false => 'web',
|
||||||
|
default => 'console',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{old: mixed, new: mixed}>
|
||||||
|
*/
|
||||||
|
protected static function diff(Model $model): array
|
||||||
|
{
|
||||||
|
$changes = [];
|
||||||
|
|
||||||
|
foreach ($model->getChanges() as $attribute => $new) {
|
||||||
|
if (in_array($attribute, self::IGNORED, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changes[$attribute] = in_array($attribute, self::SECRET, true)
|
||||||
|
? ['old' => '••••', 'new' => '••••']
|
||||||
|
: ['old' => self::value($model->getRawOriginal($attribute)), 'new' => self::value($model->getAttributes()[$attribute] ?? null)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected static function values(array $attributes): array
|
||||||
|
{
|
||||||
|
$values = [];
|
||||||
|
|
||||||
|
foreach ($attributes as $attribute => $value) {
|
||||||
|
if (in_array($attribute, self::IGNORED, true) || $value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$values[$attribute] = in_array($attribute, self::SECRET, true) ? '••••' : self::value($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Short enough to keep a log a log: what changed is here in outline, and
|
||||||
|
* in full in the record's own history.
|
||||||
|
*/
|
||||||
|
protected static function value(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if ($value instanceof \BackedEnum) {
|
||||||
|
return $value->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value instanceof \DateTimeInterface) {
|
||||||
|
return $value->format('c');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value)) {
|
||||||
|
return Str::limit($value, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value) || is_object($value)) {
|
||||||
|
return Str::limit((string) json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function label(Model $model): string
|
||||||
|
{
|
||||||
|
foreach (['title', 'name', 'full_name', 'from', 'key', 'path', 'email'] as $attribute) {
|
||||||
|
// Only real columns: `Repository::name()` is a method, and asking
|
||||||
|
// for it as an attribute would treat it as a relation.
|
||||||
|
if (! array_key_exists($attribute, $model->getAttributes())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $model->getAttribute($attribute);
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
$value = $value[app()->getLocale()] ?? reset($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method_exists($model, 'isTranslatableAttribute') && $model->isTranslatableAttribute($attribute)) {
|
||||||
|
$value = $model->getTranslation($attribute, app()->getLocale(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filled($value) && is_scalar($value)) {
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (AdminContent::nameOf($model) ?? class_basename($model)).' #'.$model->getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
12
app/Support/Batch/BatchAborted.php
Normal file
12
app/Support/Batch/BatchAborted.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Batch;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown inside the batch's transaction to undo it.
|
||||||
|
*/
|
||||||
|
final class BatchAborted extends RuntimeException {}
|
||||||
141
app/Support/Batch/BatchRunner.php
Normal file
141
app/Support/Batch/BatchRunner.php
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Batch;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Http\Kernel;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs several admin API calls as one.
|
||||||
|
*
|
||||||
|
* Each operation goes through the HTTP kernel as a request of its own, with
|
||||||
|
* the caller's credentials — so it is validated, authorised, throttled and
|
||||||
|
* answered exactly as if it had been sent separately. Nothing here knows what
|
||||||
|
* a page or a project is, which is the point: a batch cannot do anything the
|
||||||
|
* endpoints themselves would refuse.
|
||||||
|
*
|
||||||
|
* Atomic batches run inside one transaction and stop at the first failure,
|
||||||
|
* undoing everything before it. What left the database meanwhile — a queued
|
||||||
|
* job, a notification — is not undone.
|
||||||
|
*/
|
||||||
|
final class BatchRunner
|
||||||
|
{
|
||||||
|
public const MAX_OPERATIONS = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoints a batch may not call: itself (no nesting), the import that is
|
||||||
|
* built on it, and the token exchange, which is not an admin operation.
|
||||||
|
*/
|
||||||
|
protected const EXCLUDED = ['/v1/batch', '/v1/import/', '/v1/auth/'];
|
||||||
|
|
||||||
|
public function __construct(protected Kernel $kernel) {}
|
||||||
|
|
||||||
|
public static function allows(string $path): bool
|
||||||
|
{
|
||||||
|
// A plain path and an optional query string, with no way of climbing
|
||||||
|
// out of `/v1/` once the router normalises it.
|
||||||
|
if (! preg_match('#^/v1/[A-Za-z0-9_\-./]*(\?[A-Za-z0-9_\-.=&%\[\]+,]*)?$#', $path)
|
||||||
|
|| str_contains($path, '..')
|
||||||
|
|| str_contains($path, '//')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::EXCLUDED as $excluded) {
|
||||||
|
if (str_starts_with($path, $excluded)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{method: string, path: string, body?: array<string, mixed>|null}> $operations
|
||||||
|
* @param bool $atomic All or nothing.
|
||||||
|
* @param bool $rollback Undo everything even when all succeeded — a dry run.
|
||||||
|
* @return array{results: array<int, array<string, mixed>>, committed: bool}
|
||||||
|
*/
|
||||||
|
public function run(Request $parent, array $operations, bool $atomic = false, bool $rollback = false): array
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
$execute = function () use ($parent, $operations, $atomic, &$results): void {
|
||||||
|
foreach ($operations as $index => $operation) {
|
||||||
|
$result = $this->dispatch($parent, $operation);
|
||||||
|
$results[] = ['index' => $index, ...$result];
|
||||||
|
|
||||||
|
if ($atomic && $result['status'] >= 400) {
|
||||||
|
throw new BatchAborted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (! $atomic && ! $rollback) {
|
||||||
|
$execute();
|
||||||
|
|
||||||
|
return ['results' => $results, 'committed' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::transaction(function () use ($execute, $rollback): void {
|
||||||
|
$execute();
|
||||||
|
|
||||||
|
if ($rollback) {
|
||||||
|
throw new BatchAborted;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (BatchAborted) {
|
||||||
|
return ['results' => $results, 'committed' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['results' => $results, 'committed' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{method: string, path: string, body?: array<string, mixed>|null} $operation
|
||||||
|
* @return array{method: string, path: string, status: int, body: mixed}
|
||||||
|
*/
|
||||||
|
protected function dispatch(Request $parent, array $operation): array
|
||||||
|
{
|
||||||
|
$method = strtoupper($operation['method']);
|
||||||
|
$path = $operation['path'];
|
||||||
|
|
||||||
|
$request = Request::create(
|
||||||
|
'/api'.$path,
|
||||||
|
$method,
|
||||||
|
server: array_filter([
|
||||||
|
'HTTP_AUTHORIZATION' => $parent->header('Authorization'),
|
||||||
|
'HTTP_ACCEPT' => 'application/json',
|
||||||
|
'HTTP_ACCEPT_LANGUAGE' => $parent->header('Accept-Language'),
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
'REMOTE_ADDR' => $parent->ip(),
|
||||||
|
]),
|
||||||
|
content: $method === 'GET' ? null : (string) json_encode((object) ($operation['body'] ?? [])),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->kernel->handle($request);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
report($e);
|
||||||
|
|
||||||
|
return ['method' => $method, 'path' => $path, 'status' => 500, 'body' => null];
|
||||||
|
} finally {
|
||||||
|
// The kernel swapped the current request for the operation's;
|
||||||
|
// whatever runs after this one still belongs to the batch's.
|
||||||
|
app()->instance('request', $parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = $response->getContent();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'method' => $method,
|
||||||
|
'path' => $path,
|
||||||
|
'status' => $response->getStatusCode(),
|
||||||
|
'body' => $content === '' || $content === false ? null : json_decode($content, true),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
148
app/Support/Batch/Importer.php
Normal file
148
app/Support/Batch/Importer.php
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Batch;
|
||||||
|
|
||||||
|
use App\Support\AdminContent;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Spatie\Translatable\HasTranslations;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns rows — from a JSON array or a CSV file — into the create and update
|
||||||
|
* calls the API already has, and hands them to the batch runner.
|
||||||
|
*
|
||||||
|
* A row that names an existing record, by `id` or by the field given as
|
||||||
|
* `match`, updates it; any other row creates one. In a CSV, a column called
|
||||||
|
* `title.es` fills the Spanish title, a list is written `1|2|3`, `true` and
|
||||||
|
* `false` are booleans, and an empty cell is left out rather than erasing
|
||||||
|
* what is there.
|
||||||
|
*/
|
||||||
|
final class Importer
|
||||||
|
{
|
||||||
|
public const MAX_ROWS = BatchRunner::MAX_OPERATIONS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields a CSV writes as `a|b|c`.
|
||||||
|
*/
|
||||||
|
protected const LISTS = ['technology_ids', 'service_ids', 'project_ids'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function rowsFromCsv(UploadedFile $file): array
|
||||||
|
{
|
||||||
|
$handle = fopen($file->getRealPath(), 'r');
|
||||||
|
$header = null;
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
while (($line = fgetcsv($handle, escape: '\\')) !== false) {
|
||||||
|
if ($line === [null]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($header === null) {
|
||||||
|
// A byte-order mark is what a spreadsheet saving "CSV UTF-8"
|
||||||
|
// puts before the first column's name.
|
||||||
|
$header = array_map(fn ($name) => trim((string) preg_replace('/^\xEF\xBB\xBF/', '', (string) $name)), $line);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = [];
|
||||||
|
|
||||||
|
foreach ($header as $i => $column) {
|
||||||
|
$value = $line[$i] ?? '';
|
||||||
|
|
||||||
|
if ($column === '' || $value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Arr::set($row, $column, $this->cell($column, $value));
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $rows
|
||||||
|
* @return array<int, array{method: string, path: string, body: array<string, mixed>}>
|
||||||
|
*/
|
||||||
|
public function operations(string $resource, array $rows, ?string $match = null): array
|
||||||
|
{
|
||||||
|
if (count($rows) > self::MAX_ROWS) {
|
||||||
|
throw ValidationException::withMessages(['rows' => [__('rules.batch.too_many', ['max' => self::MAX_ROWS])]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(function (array $row) use ($resource, $match): array {
|
||||||
|
$id = $row['id'] ?? $this->existing($resource, $row, $match);
|
||||||
|
unset($row['id']);
|
||||||
|
|
||||||
|
return $id === null
|
||||||
|
? ['method' => 'POST', 'path' => "/v1/{$resource}", 'body' => $row]
|
||||||
|
: ['method' => 'PATCH', 'path' => "/v1/{$resource}/{$id}", 'body' => $row];
|
||||||
|
}, $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The record a row names through its `match` field, if any: a slug is
|
||||||
|
* looked up in the default language when it is translated.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
*/
|
||||||
|
protected function existing(string $resource, array $row, ?string $match): mixed
|
||||||
|
{
|
||||||
|
if ($match === null || ! array_key_exists($match, $row)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$model = AdminContent::model($resource);
|
||||||
|
$instance = new $model;
|
||||||
|
$value = $row[$match];
|
||||||
|
$translated = in_array(HasTranslations::class, class_uses_recursive($model), true)
|
||||||
|
&& in_array($match, $instance->translatable, true);
|
||||||
|
|
||||||
|
if ($translated) {
|
||||||
|
$locale = (string) config('locales.default');
|
||||||
|
$value = is_array($value) ? ($value[$locale] ?? null) : $value;
|
||||||
|
$column = "{$match}->{$locale}";
|
||||||
|
} else {
|
||||||
|
$column = $match;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === null || is_array($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = $model::query();
|
||||||
|
|
||||||
|
if (method_exists($instance, 'bootSoftDeletes')) {
|
||||||
|
$query->withTrashed();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->where($column, $value)->value($instance->getKeyName());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function cell(string $column, string $value): mixed
|
||||||
|
{
|
||||||
|
$base = explode('.', $column)[0];
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
in_array($base, self::LISTS, true) => array_map(
|
||||||
|
fn ($item) => is_numeric($item) ? (int) $item : $item,
|
||||||
|
array_values(array_filter(array_map('trim', explode('|', $value)), fn ($item) => $item !== '')),
|
||||||
|
),
|
||||||
|
strtolower($value) === 'true' => true,
|
||||||
|
strtolower($value) === 'false' => false,
|
||||||
|
strtolower($value) === 'null' => null,
|
||||||
|
default => $value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
121
app/Support/Duplicator.php
Normal file
121
app/Support/Duplicator.php
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Enums\PageStatus;
|
||||||
|
use App\Models\Call;
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Service;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A copy of a record to start the next one from: same text, same stack, same
|
||||||
|
* team, but a draft — off the site, out of any review, and with a title and
|
||||||
|
* an address that say it is a copy.
|
||||||
|
*/
|
||||||
|
final class Duplicator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<class-string<Model>, array<int, string>>
|
||||||
|
*/
|
||||||
|
public const RELATIONS = [
|
||||||
|
Page::class => ['tags'],
|
||||||
|
Project::class => ['technologies', 'services', 'members'],
|
||||||
|
Service::class => ['technologies', 'projects'],
|
||||||
|
Call::class => ['technologies'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Never copied: where it stands rather than what it says.
|
||||||
|
*/
|
||||||
|
protected const RESET = [
|
||||||
|
'published_at', 'unpublish_at', 'review_requested_at', 'review_requested_by', 'reviewer_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function supports(Model $record): bool
|
||||||
|
{
|
||||||
|
return array_key_exists($record::class, self::RELATIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function duplicate(Model $record): Model
|
||||||
|
{
|
||||||
|
if (! self::supports($record)) {
|
||||||
|
throw new InvalidArgumentException($record::class.' cannot be duplicated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read again: a table row carries aggregates — view counts, summed
|
||||||
|
// stars — that are not columns and cannot be inserted.
|
||||||
|
$record = $record->fresh() ?? $record;
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($record): Model {
|
||||||
|
$copy = $record->replicate(array_values(array_filter(
|
||||||
|
self::RESET,
|
||||||
|
fn (string $column) => array_key_exists($column, $record->getAttributes()),
|
||||||
|
)));
|
||||||
|
|
||||||
|
$titles = [];
|
||||||
|
$slugs = [];
|
||||||
|
|
||||||
|
foreach ((array) $record->getTranslations('title') as $locale => $title) {
|
||||||
|
if (filled($title)) {
|
||||||
|
$titles[$locale] = $title.' '.__('panel.duplicate.title_suffix', [], $locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ((array) $record->getTranslations('slug') as $locale => $slug) {
|
||||||
|
if (filled($slug)) {
|
||||||
|
$slugs[$locale] = self::freeSlug($record, $locale, $slug.'-'.__('panel.duplicate.slug_suffix', [], $locale));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$copy->setTranslations('title', $titles);
|
||||||
|
$copy->setTranslations('slug', $slugs);
|
||||||
|
|
||||||
|
match (true) {
|
||||||
|
$copy instanceof Page => $copy->forceFill(['status' => PageStatus::Draft, 'author_id' => Auth::id() ?? $record->author_id]),
|
||||||
|
$copy instanceof Service => $copy->forceFill(['is_published' => false]),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
$copy->save();
|
||||||
|
|
||||||
|
foreach (self::RELATIONS[$record::class] as $relation) {
|
||||||
|
$related = $record->{$relation}()->get();
|
||||||
|
|
||||||
|
$copy->{$relation}()->sync(
|
||||||
|
$relation === 'members'
|
||||||
|
? $related->mapWithKeys(fn ($member) => [$member->id => ['role' => $member->pivot->role]])->all()
|
||||||
|
: $related->pluck('id')->all(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $copy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The slug, or the slug with a number after it, whichever nobody has.
|
||||||
|
*/
|
||||||
|
protected static function freeSlug(Model $record, string $locale, string $slug): string
|
||||||
|
{
|
||||||
|
$candidate = $slug;
|
||||||
|
$n = 2;
|
||||||
|
$query = fn (string $value) => $record::query()
|
||||||
|
->when(method_exists($record, 'bootSoftDeletes'), fn ($q) => $q->withTrashed())
|
||||||
|
->where("slug->{$locale}", $value)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
while ($query($candidate)) {
|
||||||
|
$candidate = "{$slug}-{$n}";
|
||||||
|
$n++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/Support/EditorialCalendar.php
Normal file
74
app/Support/EditorialCalendar.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Enums\PageStatus;
|
||||||
|
use App\Filament\Resources\Calls\CallResource;
|
||||||
|
use App\Models\Call;
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything with a date that changes what the site shows: what went or goes
|
||||||
|
* live, what comes off, which calls open and close.
|
||||||
|
*/
|
||||||
|
final class EditorialCalendar
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return Collection<int, array{date: string, type: string, event: string, title: string, url: string, at: CarbonInterface}>
|
||||||
|
*/
|
||||||
|
public static function between(CarbonImmutable $from, CarbonImmutable $to, User $user): Collection
|
||||||
|
{
|
||||||
|
$events = collect();
|
||||||
|
|
||||||
|
$add = function (string $type, Model $record, string $event, ?CarbonInterface $at) use ($events): void {
|
||||||
|
if ($at === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$events->push([
|
||||||
|
'date' => $at->toDateString(),
|
||||||
|
'type' => $type,
|
||||||
|
'event' => $event,
|
||||||
|
'title' => ContentTypes::title($record),
|
||||||
|
'url' => $record instanceof Call ? CallResource::getUrl('edit', ['record' => $record]) : ReviewTarget::url($record),
|
||||||
|
'at' => $at,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
$inRange = fn ($query, string $column) => $query->whereBetween($column, [$from->startOfDay(), $to->endOfDay()]);
|
||||||
|
|
||||||
|
$published = [
|
||||||
|
'pages' => [Page::class, fn ($q) => $q->where('status', PageStatus::Published)],
|
||||||
|
'projects' => [Project::class, fn ($q) => $q],
|
||||||
|
'services' => [Service::class, fn ($q) => $q->where('is_published', true)],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($published as $type => [$model, $live]) {
|
||||||
|
if ($user->cannot('viewAny', $model)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$live($inRange($model::query(), 'published_at'))->get()
|
||||||
|
->each(fn (Model $r) => $add($type, $r, $r->published_at->isFuture() ? 'scheduled' : 'published', $r->published_at));
|
||||||
|
|
||||||
|
$live($inRange($model::query(), 'unpublish_at'))->get()
|
||||||
|
->each(fn (Model $r) => $add($type, $r, 'unpublishes', $r->unpublish_at));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->can('viewAny', Call::class)) {
|
||||||
|
$inRange(Call::query(), 'opens_at')->get()->each(fn (Call $c) => $add('calls', $c, 'opens', $c->opens_at));
|
||||||
|
$inRange(Call::query(), 'closes_at')->get()->each(fn (Call $c) => $add('calls', $c, 'closes', $c->closes_at));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $events->sortBy(fn (array $e) => $e['at']->getTimestamp())->values();
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/Support/Expiring.php
Normal file
83
app/Support/Expiring.php
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Enums\CallStatus;
|
||||||
|
use App\Filament\Resources\Calls\CallResource;
|
||||||
|
use App\Models\Call;
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is about to stop being true on the site: calls about to close, and
|
||||||
|
* published content about to come off by itself.
|
||||||
|
*
|
||||||
|
* Neither is a mistake — both were set on purpose — which is why the health
|
||||||
|
* check does not flag them. But a call that closes on Friday with nobody
|
||||||
|
* having replied, or a page that vanishes on the first of the month, is better
|
||||||
|
* known about a week ahead than found out after.
|
||||||
|
*/
|
||||||
|
final class Expiring
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return Collection<int, array{type: string, record: Model, title: string, event: string, at: CarbonInterface, url: string}>
|
||||||
|
*/
|
||||||
|
public static function within(int $days): Collection
|
||||||
|
{
|
||||||
|
$until = now()->addDays($days)->endOfDay();
|
||||||
|
$items = collect();
|
||||||
|
|
||||||
|
Call::query()
|
||||||
|
->where('status', CallStatus::Open)
|
||||||
|
->whereNotNull('closes_at')
|
||||||
|
->whereBetween('closes_at', [now()->startOfDay(), $until])
|
||||||
|
->get()
|
||||||
|
->each(fn (Call $call) => $items->push(self::item('calls', $call, 'closes', $call->closes_at)));
|
||||||
|
|
||||||
|
foreach (['pages' => Page::class, 'projects' => Project::class, 'services' => Service::class] as $type => $model) {
|
||||||
|
$model::query()
|
||||||
|
->published()
|
||||||
|
->whereNotNull('unpublish_at')
|
||||||
|
->whereBetween('unpublish_at', [now(), $until])
|
||||||
|
->get()
|
||||||
|
->each(fn (Model $record) => $items->push(self::item($type, $record, 'unpublishes', $record->unpublish_at)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $items->sortBy(fn (array $item) => $item['at']->getTimestamp())->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only those this person may do something about.
|
||||||
|
*
|
||||||
|
* @param Collection<int, array<string, mixed>> $items
|
||||||
|
* @return Collection<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function for(Collection $items, User $user): Collection
|
||||||
|
{
|
||||||
|
return $items->filter(fn (array $item) => $user->can('update', $item['record']))->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{type: string, record: Model, title: string, event: string, at: CarbonInterface, url: string}
|
||||||
|
*/
|
||||||
|
protected static function item(string $type, Model $record, string $event, CarbonInterface $at): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => $type,
|
||||||
|
'record' => $record,
|
||||||
|
'title' => ContentTypes::title($record),
|
||||||
|
'event' => $event,
|
||||||
|
'at' => $at,
|
||||||
|
'url' => $record instanceof Call
|
||||||
|
? CallResource::getUrl('edit', ['record' => $record])
|
||||||
|
: ReviewTarget::url($record),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Support/Inbox/Replier.php
Normal file
61
app/Support/Inbox/Replier.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Inbox;
|
||||||
|
|
||||||
|
use App\Enums\ApplicationStatus;
|
||||||
|
use App\Mail\ApplicationReplyMail;
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\ApplicationReply;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answering a submission: sending it when this application can send mail,
|
||||||
|
* handing it to the person's own mail program when it cannot, and keeping a
|
||||||
|
* record either way.
|
||||||
|
*
|
||||||
|
* "Can send" is a mailer that actually delivers. `log` and `array` accept a
|
||||||
|
* message and drop it, so with either of them a reply "sent from here" would
|
||||||
|
* be one nobody received.
|
||||||
|
*/
|
||||||
|
final class Replier
|
||||||
|
{
|
||||||
|
public static function canSendMail(): bool
|
||||||
|
{
|
||||||
|
return ! in_array(config('mail.default'), ['log', 'array', null], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reply(Application $application, string $subject, string $body, User $by): ApplicationReply
|
||||||
|
{
|
||||||
|
$send = self::canSendMail();
|
||||||
|
|
||||||
|
$reply = $application->replies()->create([
|
||||||
|
'user_id' => $by->id,
|
||||||
|
'user_name' => $by->name,
|
||||||
|
'subject' => $subject,
|
||||||
|
'body' => $body,
|
||||||
|
'channel' => $send ? ApplicationReply::MAIL : ApplicationReply::MAILTO,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($send) {
|
||||||
|
Mail::to($application->email, $application->name)->send(new ApplicationReplyMail($reply));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answered is at least contacted; a decision already taken stays.
|
||||||
|
if ($application->status === ApplicationStatus::New) {
|
||||||
|
$application->markAs(ApplicationStatus::Contacted, $by);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same answer, for the person's own mail program to send.
|
||||||
|
*/
|
||||||
|
public static function mailto(Application $application, string $subject, string $body): string
|
||||||
|
{
|
||||||
|
return 'mailto:'.$application->email.'?subject='.rawurlencode($subject).'&body='.rawurlencode($body);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/Support/PendingTranslations.php
Normal file
81
app/Support/PendingTranslations.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\Call;
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records with a language still to write (`missing`) or left behind by the
|
||||||
|
* one it was translated from (`stale`, with since when) — for the panel's
|
||||||
|
* translation assistant and the API alike.
|
||||||
|
*/
|
||||||
|
final class PendingTranslations
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<string, class-string<Model>>
|
||||||
|
*/
|
||||||
|
public const TYPES = [
|
||||||
|
'pages' => Page::class,
|
||||||
|
'projects' => Project::class,
|
||||||
|
'services' => Service::class,
|
||||||
|
'calls' => Call::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, array{type: string, id: int|string, record: Model, title: string, missing: array<int, string>, stale: array<string, CarbonInterface>}>
|
||||||
|
*/
|
||||||
|
public static function find(User $user, ?string $locale = null, ?string $type = null, bool $publishedOnly = false): Collection
|
||||||
|
{
|
||||||
|
$locales = array_keys((array) config('locales.supported'));
|
||||||
|
$source = (string) config('locales.default');
|
||||||
|
$items = collect();
|
||||||
|
|
||||||
|
foreach (self::TYPES as $name => $model) {
|
||||||
|
if (($type !== null && $type !== $name) || $user->cannot('viewAny', $model)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$model::query()
|
||||||
|
->when($publishedOnly, fn ($q) => $model === Call::class ? $q->open() : $q->published())
|
||||||
|
->with('revisions')
|
||||||
|
->get()
|
||||||
|
->each(function (Model $record) use ($locales, $source, $locale, $name, $items): void {
|
||||||
|
$missing = array_values(array_filter(
|
||||||
|
$locales,
|
||||||
|
fn (string $l) => $l !== $source && blank($record->getTranslation('title', $l, false)),
|
||||||
|
));
|
||||||
|
|
||||||
|
$stale = TranslationFreshness::for($record);
|
||||||
|
|
||||||
|
if ($locale !== null) {
|
||||||
|
$missing = array_values(array_intersect($missing, [$locale]));
|
||||||
|
$stale = array_intersect_key($stale, [$locale => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($missing === [] && $stale === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items->push([
|
||||||
|
'type' => $name,
|
||||||
|
'id' => $record->getKey(),
|
||||||
|
'record' => $record,
|
||||||
|
'title' => ContentTypes::title($record),
|
||||||
|
'missing' => $missing,
|
||||||
|
'stale' => $stale,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $items->values();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,13 @@ final class Permissions
|
|||||||
// the provider's rate limit, which is a shared resource.
|
// the provider's rate limit, which is a shared resource.
|
||||||
'repositories' => ['view_any', 'view', 'create', 'update', 'delete', 'sync'],
|
'repositories' => ['view_any', 'view', 'create', 'update', 'delete', 'sync'],
|
||||||
|
|
||||||
|
// Where the site tells other systems what happened. A webhook sends
|
||||||
|
// data off this server, so it is an administrator's decision.
|
||||||
|
'webhooks' => ['view_any', 'view', 'create', 'update', 'delete'],
|
||||||
|
|
||||||
|
// Who did what, from where. It names people and their addresses.
|
||||||
|
'audit' => ['view_any'],
|
||||||
|
|
||||||
'users' => ['view_any', 'view', 'create', 'update', 'delete'],
|
'users' => ['view_any', 'view', 'create', 'update', 'delete'],
|
||||||
'roles' => ['view_any', 'view', 'create', 'update', 'delete'],
|
'roles' => ['view_any', 'view', 'create', 'update', 'delete'],
|
||||||
|
|
||||||
|
|||||||
53
app/Support/Publisher.php
Normal file
53
app/Support/Publisher.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Enums\PageStatus;
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Service;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Putting a record on the site, or taking it off, whichever fields that means
|
||||||
|
* for its kind: a page has a status, a project only a date, a service a flag
|
||||||
|
* and a date.
|
||||||
|
*/
|
||||||
|
final class Publisher
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Live now, or from `$at` when it is in the future. Whatever review it was
|
||||||
|
* waiting for is answered either way: scheduling is the decision.
|
||||||
|
*/
|
||||||
|
public static function publish(Model $record, ?CarbonInterface $at = null): void
|
||||||
|
{
|
||||||
|
$at ??= now();
|
||||||
|
|
||||||
|
$record->forceFill(match (true) {
|
||||||
|
$record instanceof Page => ['status' => PageStatus::Published, 'published_at' => $at],
|
||||||
|
$record instanceof Project => ['published_at' => $at],
|
||||||
|
$record instanceof Service => ['is_published' => true, 'published_at' => $at],
|
||||||
|
default => throw new InvalidArgumentException($record::class.' is not published this way.'),
|
||||||
|
})->save();
|
||||||
|
|
||||||
|
// Publishing now closes the review by itself (HasReviews); a date in
|
||||||
|
// the future does not, so it is closed here.
|
||||||
|
if ($record->isAwaitingReview()) {
|
||||||
|
$record->closeReview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function unpublish(Model $record): void
|
||||||
|
{
|
||||||
|
$record->forceFill(match (true) {
|
||||||
|
$record instanceof Page => ['status' => PageStatus::Draft],
|
||||||
|
$record instanceof Project => ['published_at' => null],
|
||||||
|
$record instanceof Service => ['is_published' => false],
|
||||||
|
default => throw new InvalidArgumentException($record::class.' is not published this way.'),
|
||||||
|
})->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
201
app/Support/Search/SiteSearch.php
Normal file
201
app/Support/Search/SiteSearch.php
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Search;
|
||||||
|
|
||||||
|
use App\Models\Call;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\Technology;
|
||||||
|
use App\Support\SiteUrls;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searching what the public site shows, in one language.
|
||||||
|
*
|
||||||
|
* On Postgres it is full-text search: words are stemmed in the language being
|
||||||
|
* searched, so "proyectos" finds "proyecto", and results are ranked with the
|
||||||
|
* title weighing more than the body. Anywhere else — SQLite in the test suite,
|
||||||
|
* a laptop without Postgres — it falls back to matching the words as written,
|
||||||
|
* which finds less and ranks crudely but answers the same shape.
|
||||||
|
*
|
||||||
|
* Only what is published: a search is a way of reaching the site's pages, and
|
||||||
|
* a draft has none.
|
||||||
|
*/
|
||||||
|
final class SiteSearch
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Postgres text search configuration per language. Catalan has no stemmer
|
||||||
|
* in a stock Postgres, so it is matched word for word.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
protected const DICTIONARIES = ['es' => 'spanish', 'en' => 'english'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type => what is searched in it: the title field, the fields with
|
||||||
|
* weight B, and whether those fields are translatable.
|
||||||
|
*
|
||||||
|
* @var array<string, array{model: class-string<Model>, title: string, translatedTitle: bool, text: array<int, string>}>
|
||||||
|
*/
|
||||||
|
protected const SOURCES = [
|
||||||
|
'page' => ['model' => Page::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['excerpt', 'body']],
|
||||||
|
'project' => ['model' => Project::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['tagline', 'description']],
|
||||||
|
'service' => ['model' => Service::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['tagline', 'description']],
|
||||||
|
'call' => ['model' => Call::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['description']],
|
||||||
|
'member' => ['model' => Member::class, 'title' => 'name', 'translatedTitle' => false, 'text' => ['headline', 'bio']],
|
||||||
|
'technology' => ['model' => Technology::class, 'title' => 'name', 'translatedTitle' => false, 'text' => ['description']],
|
||||||
|
];
|
||||||
|
|
||||||
|
public const TYPES = ['page', 'project', 'service', 'call', 'member', 'technology'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $types
|
||||||
|
* @return Collection<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function search(string $query, string $locale, array $types = self::TYPES, int $limit = 20): Collection
|
||||||
|
{
|
||||||
|
$query = trim($query);
|
||||||
|
|
||||||
|
if (mb_strlen($query) < 2) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$postgres = DB::connection()->getDriverName() === 'pgsql';
|
||||||
|
|
||||||
|
return collect(self::SOURCES)
|
||||||
|
->only($types)
|
||||||
|
->flatMap(fn (array $source, string $type) => $this->searchIn($type, $source, $query, $locale, $postgres, $limit))
|
||||||
|
->sortByDesc('score')
|
||||||
|
->take($limit)
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{model: class-string<Model>, title: string, translatedTitle: bool, text: array<int, string>} $source
|
||||||
|
* @return Collection<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
protected function searchIn(string $type, array $source, string $query, string $locale, bool $postgres, int $limit): Collection
|
||||||
|
{
|
||||||
|
$builder = $this->visible($source['model']::query(), $source['model']);
|
||||||
|
|
||||||
|
$title = $source['translatedTitle'] ? $this->json($source['title'], $locale, $postgres) : $source['title'];
|
||||||
|
$text = collect($source['text'])->map(fn (string $field) => $this->json($field, $locale, $postgres));
|
||||||
|
|
||||||
|
if ($postgres) {
|
||||||
|
$dictionary = self::DICTIONARIES[$locale] ?? 'simple';
|
||||||
|
// Tags out of rich text, so `<strong>` is not a word anyone finds.
|
||||||
|
$body = $text->map(fn (string $sql) => "regexp_replace(coalesce({$sql}, ''), '<[^>]+>', ' ', 'g')")->implode(" || ' ' || ");
|
||||||
|
$document = "setweight(to_tsvector('{$dictionary}', coalesce({$title}, '')), 'A') || setweight(to_tsvector('{$dictionary}', {$body}), 'B')";
|
||||||
|
$tsquery = "websearch_to_tsquery('{$dictionary}', ?)";
|
||||||
|
|
||||||
|
$builder
|
||||||
|
->select('*')
|
||||||
|
->selectRaw("ts_rank({$document}, {$tsquery}) as search_score", [$query])
|
||||||
|
->whereRaw("{$document} @@ {$tsquery}", [$query])
|
||||||
|
->orderByDesc('search_score');
|
||||||
|
} else {
|
||||||
|
$words = collect(preg_split('/\s+/', $query))->filter()->map(fn ($w) => '%'.mb_strtolower($w).'%');
|
||||||
|
|
||||||
|
foreach ($words as $word) {
|
||||||
|
$builder->where(function (Builder $q) use ($title, $text, $word): void {
|
||||||
|
$q->whereRaw("lower({$title}) like ?", [$word]);
|
||||||
|
|
||||||
|
foreach ($text as $sql) {
|
||||||
|
$q->orWhereRaw("lower({$sql}) like ?", [$word]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $builder->limit($limit)->get()->map(fn (Model $record) => $this->result($type, $source, $record, $query, $locale));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{model: class-string<Model>, title: string, translatedTitle: bool, text: array<int, string>} $source
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function result(string $type, array $source, Model $record, string $query, string $locale): array
|
||||||
|
{
|
||||||
|
$title = $source['translatedTitle']
|
||||||
|
? (string) $record->getTranslation($source['title'], $locale, false)
|
||||||
|
: (string) $record->getAttribute($source['title']);
|
||||||
|
|
||||||
|
$text = collect($source['text'])
|
||||||
|
->map(fn (string $field) => (string) $record->getTranslation($field, $locale, false))
|
||||||
|
->implode(' ');
|
||||||
|
|
||||||
|
// Crude where Postgres did not rank: a title match counts more.
|
||||||
|
$score = $record->getAttribute('search_score')
|
||||||
|
?? (Str::contains(mb_strtolower($title), mb_strtolower($query)) ? 2.0 : 1.0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => $type,
|
||||||
|
'id' => $record->getKey(),
|
||||||
|
'title' => $title,
|
||||||
|
'excerpt' => $this->excerpt($text, $query),
|
||||||
|
'path' => $this->path($type, $record, $locale),
|
||||||
|
'score' => round((float) $score, 4),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Published, open or public, whichever each kind calls it.
|
||||||
|
*
|
||||||
|
* @param class-string<Model> $model
|
||||||
|
*/
|
||||||
|
protected function visible(Builder $query, string $model): Builder
|
||||||
|
{
|
||||||
|
return match ($model) {
|
||||||
|
Call::class => $query->open(),
|
||||||
|
Member::class => $query->public(),
|
||||||
|
// A technology is shown on the site once something uses it.
|
||||||
|
Technology::class => $query->whereHas('projects', fn (Builder $q) => $q->published()),
|
||||||
|
default => $query->published(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text in one language of a translatable JSON column.
|
||||||
|
*/
|
||||||
|
protected function json(string $field, string $locale, bool $postgres): string
|
||||||
|
{
|
||||||
|
return $postgres
|
||||||
|
? "({$field}->>'{$locale}')"
|
||||||
|
: "json_extract({$field}, '$.{$locale}')";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some words either side of the first match, out of plain text.
|
||||||
|
*/
|
||||||
|
protected function excerpt(string $html, string $query): string
|
||||||
|
{
|
||||||
|
$text = trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($html))));
|
||||||
|
$first = collect(preg_split('/\s+/', $query))->filter()->first() ?? $query;
|
||||||
|
$at = mb_stripos($text, (string) $first);
|
||||||
|
|
||||||
|
if ($at === false || $at < 60) {
|
||||||
|
return Str::limit($text, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '…'.Str::limit(mb_substr($text, $at - 60), 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function path(string $type, Model $record, string $locale): ?string
|
||||||
|
{
|
||||||
|
return match ($type) {
|
||||||
|
'member' => SiteUrls::path('member', $locale, (string) $record->slug),
|
||||||
|
// The site gives technologies pages, but this side has no address
|
||||||
|
// for them: the slug is enough to build one.
|
||||||
|
'technology' => null,
|
||||||
|
default => SiteUrls::alternates($record, $type)[$locale] ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
99
app/Support/TextDiff.php
Normal file
99
app/Support/TextDiff.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What changed between two versions of a text, word by word, as HTML with
|
||||||
|
* `<del>` and `<ins>` — enough to show a translator which sentence of the
|
||||||
|
* original to look at again.
|
||||||
|
*/
|
||||||
|
final class TextDiff
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Above this many words on either side the comparison is skipped: it is
|
||||||
|
* quadratic, and a text that long changed is better reread whole.
|
||||||
|
*/
|
||||||
|
protected const MAX_WORDS = 1500;
|
||||||
|
|
||||||
|
public static function html(?string $old, ?string $new): ?string
|
||||||
|
{
|
||||||
|
$a = self::words($old);
|
||||||
|
$b = self::words($new);
|
||||||
|
|
||||||
|
if (count($a) > self::MAX_WORDS || count($b) > self::MAX_WORDS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Longest common subsequence, then walked back into runs.
|
||||||
|
$m = count($a);
|
||||||
|
$n = count($b);
|
||||||
|
$lcs = array_fill(0, $m + 1, array_fill(0, $n + 1, 0));
|
||||||
|
|
||||||
|
for ($i = $m - 1; $i >= 0; $i--) {
|
||||||
|
for ($j = $n - 1; $j >= 0; $j--) {
|
||||||
|
$lcs[$i][$j] = $a[$i] === $b[$j] ? $lcs[$i + 1][$j + 1] + 1 : max($lcs[$i + 1][$j], $lcs[$i][$j + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
$i = $j = 0;
|
||||||
|
|
||||||
|
while ($i < $m || $j < $n) {
|
||||||
|
if ($i < $m && $j < $n && $a[$i] === $b[$j]) {
|
||||||
|
$out[] = ['=', $a[$i]];
|
||||||
|
$i++;
|
||||||
|
$j++;
|
||||||
|
} elseif ($i < $m && ($j === $n || $lcs[$i + 1][$j] >= $lcs[$i][$j + 1])) {
|
||||||
|
// What went comes before what replaced it.
|
||||||
|
$out[] = ['-', $a[$i]];
|
||||||
|
$i++;
|
||||||
|
} else {
|
||||||
|
$out[] = ['+', $b[$j]];
|
||||||
|
$j++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = '';
|
||||||
|
$run = null;
|
||||||
|
$buffer = [];
|
||||||
|
|
||||||
|
$flush = function () use (&$html, &$run, &$buffer): void {
|
||||||
|
if ($buffer === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = e(implode(' ', $buffer));
|
||||||
|
$html .= match ($run) {
|
||||||
|
'+' => "<ins>{$text}</ins> ",
|
||||||
|
'-' => "<del>{$text}</del> ",
|
||||||
|
default => "{$text} ",
|
||||||
|
};
|
||||||
|
$buffer = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach ($out as [$op, $word]) {
|
||||||
|
if ($op !== $run) {
|
||||||
|
$flush();
|
||||||
|
$run = $op;
|
||||||
|
}
|
||||||
|
|
||||||
|
$buffer[] = $word;
|
||||||
|
}
|
||||||
|
|
||||||
|
$flush();
|
||||||
|
|
||||||
|
return trim($html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
protected static function words(?string $text): array
|
||||||
|
{
|
||||||
|
$plain = trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags(str_replace(['</p>', '<br>', '<br/>', '<br />'], ' ', (string) $text)))));
|
||||||
|
|
||||||
|
return $plain === '' ? [] : explode(' ', $plain);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,69 @@ final class TranslationFreshness
|
|||||||
/**
|
/**
|
||||||
* @return array<string, CarbonInterface>
|
* @return array<string, CarbonInterface>
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Field by field, for one language: which fields the original changed
|
||||||
|
* after this language was last written, since when, and what the
|
||||||
|
* original said back then — so a translator can see what to redo rather
|
||||||
|
* than rereading the whole text.
|
||||||
|
*
|
||||||
|
* @return array<string, array{since: CarbonInterface, was: ?string}>
|
||||||
|
*/
|
||||||
|
public static function staleFields(Model $record, string $locale): array
|
||||||
|
{
|
||||||
|
$source = (string) config('locales.default');
|
||||||
|
$fields = self::fields($record);
|
||||||
|
$touched = self::lastTouched($record, $fields);
|
||||||
|
$stale = [];
|
||||||
|
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
if ($locale === $source || blank($record->getTranslation($field, $locale, false))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceAt = $touched[$field][$source] ?? null;
|
||||||
|
$translatedAt = $touched[$field][$locale] ?? $record->created_at;
|
||||||
|
|
||||||
|
if ($sourceAt !== null && $translatedAt !== null && $sourceAt->gt($translatedAt)) {
|
||||||
|
$stale[$field] = [
|
||||||
|
'since' => $sourceAt,
|
||||||
|
'was' => self::sourceAsOf($record, $field, $translatedAt),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The original's text in a field as it stood at a moment, from the
|
||||||
|
* history: the last value written at or before it, or the value the first
|
||||||
|
* later change replaced.
|
||||||
|
*/
|
||||||
|
protected static function sourceAsOf(Model $record, string $field, CarbonInterface $at): ?string
|
||||||
|
{
|
||||||
|
$source = (string) config('locales.default');
|
||||||
|
$value = null;
|
||||||
|
$found = false;
|
||||||
|
|
||||||
|
foreach ($record->revisions()->reorder()->orderBy('id')->get() as $revision) {
|
||||||
|
$change = $revision->changeset[$field] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($change)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($revision->created_at->lte($at)) {
|
||||||
|
$value = ((array) ($change['new'] ?? []))[$source] ?? null;
|
||||||
|
$found = true;
|
||||||
|
} elseif (! $found) {
|
||||||
|
return ((array) ($change['old'] ?? []))[$source] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $found ? $value : $record->getTranslation($field, $source, false);
|
||||||
|
}
|
||||||
|
|
||||||
protected static function compute(Model $record): array
|
protected static function compute(Model $record): array
|
||||||
{
|
{
|
||||||
$source = (string) config('locales.default');
|
$source = (string) config('locales.default');
|
||||||
|
|||||||
192
app/Support/ViewStats.php
Normal file
192
app/Support/ViewStats.php
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Calls\CallResource;
|
||||||
|
use App\Filament\Resources\Members\MemberResource;
|
||||||
|
use App\Models\Call;
|
||||||
|
use App\Models\ContentView;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Service;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What was read, from the daily per-language totals the frontend reports.
|
||||||
|
*
|
||||||
|
* Every figure is compared with the period just before it of the same length:
|
||||||
|
* "320 reads" says little, "320, up from 210" says whether something worked.
|
||||||
|
*/
|
||||||
|
final class ViewStats
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<string, class-string<Model>>
|
||||||
|
*/
|
||||||
|
protected const TYPES = [
|
||||||
|
'pages' => Page::class,
|
||||||
|
'projects' => Project::class,
|
||||||
|
'services' => Service::class,
|
||||||
|
'calls' => Call::class,
|
||||||
|
'members' => Member::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(public readonly int $days) {}
|
||||||
|
|
||||||
|
public function from(): CarbonImmutable
|
||||||
|
{
|
||||||
|
return CarbonImmutable::today()->subDays($this->days - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function previousFrom(): CarbonImmutable
|
||||||
|
{
|
||||||
|
return $this->from()->subDays($this->days);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, array{type: string, title: string, url: ?string, views: int, previous: int}>
|
||||||
|
*/
|
||||||
|
public function top(int $limit = 10): Collection
|
||||||
|
{
|
||||||
|
$current = ContentView::query()
|
||||||
|
->where('day', '>=', $this->from()->toDateString())
|
||||||
|
->selectRaw('viewable_type, viewable_id, SUM(count) as total')
|
||||||
|
->groupBy('viewable_type', 'viewable_id')
|
||||||
|
->orderByDesc('total')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$previous = ContentView::query()
|
||||||
|
->whereBetween('day', [$this->previousFrom()->toDateString(), $this->from()->subDay()->toDateString()])
|
||||||
|
->where(function ($q) use ($current): void {
|
||||||
|
foreach ($current as $row) {
|
||||||
|
$q->orWhere(fn ($q) => $q->where('viewable_type', $row->viewable_type)->where('viewable_id', $row->viewable_id));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->selectRaw('viewable_type, viewable_id, SUM(count) as total')
|
||||||
|
->groupBy('viewable_type', 'viewable_id')
|
||||||
|
->get()
|
||||||
|
->keyBy(fn ($row) => $row->viewable_type.':'.$row->viewable_id);
|
||||||
|
|
||||||
|
return $current
|
||||||
|
->map(function ($row) use ($previous): ?array {
|
||||||
|
$record = $row->viewable;
|
||||||
|
|
||||||
|
if (! $record instanceof Model) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => array_search($record::class, self::TYPES, true) ?: class_basename($record),
|
||||||
|
'title' => $record instanceof Member ? $record->name : ContentTypes::title($record),
|
||||||
|
'url' => self::url($record),
|
||||||
|
'views' => (int) $row->total,
|
||||||
|
'previous' => (int) ($previous[$row->viewable_type.':'.$row->viewable_id]->total ?? 0),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->filter()
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads per language, now and in the period before.
|
||||||
|
*
|
||||||
|
* @return Collection<string, array{views: int, previous: int, share: float}>
|
||||||
|
*/
|
||||||
|
public function byLocale(): Collection
|
||||||
|
{
|
||||||
|
$sum = fn (string $from, string $to) => ContentView::query()
|
||||||
|
->whereBetween('day', [$from, $to])
|
||||||
|
->selectRaw('locale, SUM(count) as total')
|
||||||
|
->groupBy('locale')
|
||||||
|
->pluck('total', 'locale');
|
||||||
|
|
||||||
|
$now = $sum($this->from()->toDateString(), CarbonImmutable::today()->toDateString());
|
||||||
|
$before = $sum($this->previousFrom()->toDateString(), $this->from()->subDay()->toDateString());
|
||||||
|
$total = max(1, (int) $now->sum());
|
||||||
|
|
||||||
|
return collect(array_keys((array) config('locales.supported')))
|
||||||
|
->mapWithKeys(fn (string $locale) => [$locale => [
|
||||||
|
'views' => (int) ($now[$locale] ?? 0),
|
||||||
|
'previous' => (int) ($before[$locale] ?? 0),
|
||||||
|
'share' => round(((int) ($now[$locale] ?? 0)) * 100 / $total, 1),
|
||||||
|
]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day by day, per language, for the chart.
|
||||||
|
*
|
||||||
|
* @return array{labels: array<int, string>, series: array<string, array<int, int>>}
|
||||||
|
*/
|
||||||
|
public function daily(): array
|
||||||
|
{
|
||||||
|
$rows = ContentView::query()
|
||||||
|
->where('day', '>=', $this->from()->toDateString())
|
||||||
|
->selectRaw('day, locale, SUM(count) as total')
|
||||||
|
->groupBy('day', 'locale')
|
||||||
|
->get()
|
||||||
|
->groupBy(fn ($row) => CarbonImmutable::parse($row->day)->toDateString());
|
||||||
|
|
||||||
|
$labels = [];
|
||||||
|
$series = array_fill_keys(array_keys((array) config('locales.supported')), []);
|
||||||
|
|
||||||
|
for ($day = $this->from(); $day->lte(CarbonImmutable::today()); $day = $day->addDay()) {
|
||||||
|
$labels[] = $day->isoFormat('D MMM');
|
||||||
|
$today = ($rows[$day->toDateString()] ?? collect())->pluck('total', 'locale');
|
||||||
|
|
||||||
|
foreach (array_keys($series) as $locale) {
|
||||||
|
$series[$locale][] = (int) ($today[$locale] ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['labels' => $labels, 'series' => $series];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On the site and read by nobody in the period: the content that is
|
||||||
|
* either hard to find or not worth finding.
|
||||||
|
*
|
||||||
|
* @return Collection<int, array{type: string, title: string, url: ?string}>
|
||||||
|
*/
|
||||||
|
public function unread(int $limit = 50): Collection
|
||||||
|
{
|
||||||
|
$items = collect();
|
||||||
|
|
||||||
|
foreach (self::TYPES as $type => $model) {
|
||||||
|
$read = ContentView::query()
|
||||||
|
->where('viewable_type', (new $model)->getMorphClass())
|
||||||
|
->where('day', '>=', $this->from()->toDateString())
|
||||||
|
->distinct()
|
||||||
|
->pluck('viewable_id');
|
||||||
|
|
||||||
|
$model::query()
|
||||||
|
->when($model === Call::class, fn ($q) => $q->open())
|
||||||
|
->when($model === Member::class, fn ($q) => $q->public())
|
||||||
|
->unless(in_array($model, [Call::class, Member::class], true), fn ($q) => $q->published())
|
||||||
|
->whereNotIn((new $model)->getKeyName(), $read)
|
||||||
|
->limit($limit)
|
||||||
|
->get()
|
||||||
|
->each(fn (Model $record) => $items->push([
|
||||||
|
'type' => $type,
|
||||||
|
'title' => $record instanceof Member ? $record->name : ContentTypes::title($record),
|
||||||
|
'url' => self::url($record),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $items->take($limit)->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function url(Model $record): ?string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
$record instanceof Call => CallResource::getUrl('edit', ['record' => $record]),
|
||||||
|
$record instanceof Member => MemberResource::getUrl('edit', ['record' => $record]),
|
||||||
|
$record instanceof Page, $record instanceof Project, $record instanceof Service => ReviewTarget::url($record),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/Support/Webhooks/PublicEndpoint.php
Normal file
52
app/Support/Webhooks/PublicEndpoint.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Webhooks;
|
||||||
|
|
||||||
|
use App\Services\Media\HostResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a URL is somewhere this server may send a request to: HTTPS, and a
|
||||||
|
* host whose every address is public.
|
||||||
|
*
|
||||||
|
* The same rule the image downloader follows, for the same reason — the
|
||||||
|
* request leaves from inside the stack, and without it a webhook pointed at
|
||||||
|
* `http://postgres:5432` or the cloud's metadata service would be a way of
|
||||||
|
* reaching them.
|
||||||
|
*/
|
||||||
|
final class PublicEndpoint
|
||||||
|
{
|
||||||
|
public function __construct(protected HostResolver $resolver) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The address to connect to, or why not: `https_only`, `unresolvable` or
|
||||||
|
* `private`.
|
||||||
|
*
|
||||||
|
* @return array{0: ?string, 1: ?string} [address, reason]
|
||||||
|
*/
|
||||||
|
public function check(string $url): array
|
||||||
|
{
|
||||||
|
$parts = parse_url($url);
|
||||||
|
$host = $parts['host'] ?? null;
|
||||||
|
|
||||||
|
if (($parts['scheme'] ?? null) !== 'https' || blank($host)) {
|
||||||
|
return [null, 'https_only'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = trim((string) $host, '[]');
|
||||||
|
$addresses = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : $this->resolver->resolve($host);
|
||||||
|
|
||||||
|
if ($addresses === []) {
|
||||||
|
return [null, 'unresolvable'];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($addresses as $address) {
|
||||||
|
if (! filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE)) {
|
||||||
|
return [null, 'private'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$addresses[0], null];
|
||||||
|
}
|
||||||
|
}
|
||||||
127
app/Support/Webhooks/Webhooks.php
Normal file
127
app/Support/Webhooks/Webhooks.php
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Webhooks;
|
||||||
|
|
||||||
|
use App\Jobs\DeliverWebhook;
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\ReviewComment;
|
||||||
|
use App\Models\Revision;
|
||||||
|
use App\Models\Webhook;
|
||||||
|
use App\Support\AdminContent;
|
||||||
|
use App\Support\ContentTypes;
|
||||||
|
use App\Support\SiteUrls;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turning what happens here into deliveries for whoever listens.
|
||||||
|
*
|
||||||
|
* Fed by model events rather than by the controllers, so a page published from
|
||||||
|
* the panel, the API or a batch is announced the same way.
|
||||||
|
*/
|
||||||
|
final class Webhooks
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @param array<string, mixed> $personal Sent only to webhooks that opted into personal data.
|
||||||
|
*/
|
||||||
|
public static function dispatch(string $event, array $data, array $personal = []): void
|
||||||
|
{
|
||||||
|
$webhooks = Webhook::query()->active()->get()->filter(fn (Webhook $w) => $w->listensTo($event));
|
||||||
|
|
||||||
|
foreach ($webhooks as $webhook) {
|
||||||
|
$delivery = $webhook->deliveries()->create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'event' => $event,
|
||||||
|
'payload' => $webhook->include_personal_data ? [...$data, ...$personal] : $data,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DeliverWebhook::dispatch($delivery)->afterCommit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function applicationReceived(Application $application): void
|
||||||
|
{
|
||||||
|
self::dispatch('application.received', [
|
||||||
|
'id' => $application->id,
|
||||||
|
'kind' => $application->kind->value,
|
||||||
|
'call_id' => $application->call_id,
|
||||||
|
'subject' => $application->subject(),
|
||||||
|
'locale' => $application->locale,
|
||||||
|
'created_at' => $application->created_at?->toIso8601String(),
|
||||||
|
], [
|
||||||
|
'name' => $application->name,
|
||||||
|
'email' => $application->email,
|
||||||
|
'organisation' => $application->organisation,
|
||||||
|
'message' => $application->message,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishing and unpublishing are already told apart by the history, for
|
||||||
|
* every kind of record and from wherever it happened; this listens there.
|
||||||
|
*/
|
||||||
|
public static function revisionRecorded(Revision $revision): void
|
||||||
|
{
|
||||||
|
$model = Relation::getMorphedModel($revision->revisionable_type) ?? $revision->revisionable_type;
|
||||||
|
$record = $model::query()->find($revision->revisionable_id);
|
||||||
|
|
||||||
|
if ($record === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$event = match (true) {
|
||||||
|
$revision->event === 'published' => 'content.published',
|
||||||
|
$revision->event === 'unpublished' => 'content.unpublished',
|
||||||
|
$revision->event === 'created' && method_exists($record, 'isPublishedNow') && $record->isPublishedNow() => 'content.published',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($event !== null) {
|
||||||
|
self::dispatch($event, self::describe($record));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reviewRecorded(ReviewComment $comment): void
|
||||||
|
{
|
||||||
|
$event = match ($comment->event->value) {
|
||||||
|
'requested' => 'review.requested',
|
||||||
|
'returned' => 'review.returned',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
$record = $comment->reviewable;
|
||||||
|
|
||||||
|
if ($event === null || $record === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::dispatch($event, [
|
||||||
|
...self::describe($record),
|
||||||
|
'by' => $comment->user_name,
|
||||||
|
'note' => $comment->body,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected static function describe(Model $record): array
|
||||||
|
{
|
||||||
|
$type = AdminContent::nameOf($record);
|
||||||
|
$siteType = AdminContent::PREVIEWABLE[$type] ?? null;
|
||||||
|
$locale = (string) config('locales.default');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => $type,
|
||||||
|
'id' => $record->getKey(),
|
||||||
|
'title' => ContentTypes::title($record),
|
||||||
|
'url' => $siteType && method_exists($record, 'getTranslation')
|
||||||
|
? (SiteUrls::absoluteAlternates($record, $siteType)[$locale] ?? null)
|
||||||
|
: null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
20
config/audit.php
Normal file
20
config/audit.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Retention
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Days an entry is kept. It names people and the addresses they worked
|
||||||
|
| from, so it is not kept forever; `php artisan model:prune` removes what
|
||||||
|
| is older, and the schedule runs it daily.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'retention_days' => (int) env('AUDIT_RETENTION_DAYS', 365),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -115,4 +115,11 @@ return [
|
|||||||
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
|
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Where answers to the inbox's replies go back to. Unset, they go to the
|
||||||
|
// "from" address.
|
||||||
|
'reply_to' => [
|
||||||
|
'address' => env('MAIL_REPLY_TO_ADDRESS'),
|
||||||
|
'name' => env('MAIL_REPLY_TO_NAME', env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel'))),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -105,7 +105,10 @@ return [
|
|||||||
* Set this to false if you want to implement custom logic for checking permissions.
|
* Set this to false if you want to implement custom logic for checking permissions.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'register_permission_check_method' => true,
|
// Off: App\Providers\AuthServiceProvider registers the one check, so a
|
||||||
|
// machine token's narrower abilities are asked before any permission is
|
||||||
|
// granted rather than after.
|
||||||
|
'register_permission_check_method' => false,
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered
|
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered
|
||||||
@@ -123,7 +126,8 @@ return [
|
|||||||
*
|
*
|
||||||
* To enable, set to true, and then create listeners to watch these events.
|
* To enable, set to true, and then create listeners to watch these events.
|
||||||
*/
|
*/
|
||||||
'events_enabled' => false,
|
// On: role changes are written to the audit log (AppServiceProvider).
|
||||||
|
'events_enabled' => true,
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Teams Feature.
|
* Teams Feature.
|
||||||
|
|||||||
@@ -165,4 +165,17 @@ return [
|
|||||||
// the notification bell is always on, and mail needs a working mailer.
|
// the notification bell is always on, and mail needs a working mailer.
|
||||||
'notify_by_mail' => (bool) env('SITE_SUBMISSION_MAIL', false),
|
'notify_by_mail' => (bool) env('SITE_SUBMISSION_MAIL', false),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Expiry reminders
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Days ahead of a call closing, or of published content coming off the
|
||||||
|
| site, on which `content:expiring` tells the people who edit it.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'expiry_reminders' => [7, 1],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
28
database/factories/ReplyTemplateFactory.php
Normal file
28
database/factories/ReplyTemplateFactory.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\ReplyTemplate;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<ReplyTemplate>
|
||||||
|
*/
|
||||||
|
class ReplyTemplateFactory extends Factory
|
||||||
|
{
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $this->faker->words(3, true),
|
||||||
|
'kind' => null,
|
||||||
|
'subject' => ['es' => 'Gracias, {name}', 'en' => 'Thank you, {name}', 'ca' => 'Gràcies, {name}'],
|
||||||
|
'body' => [
|
||||||
|
'es' => "Hola {name}:\n\nGracias por escribirnos sobre «{subject}».\n\n{sender}",
|
||||||
|
'en' => "Hi {name},\n\nThank you for writing to us about \"{subject}\".\n\n{sender}",
|
||||||
|
'ca' => "Hola {name}:\n\nGràcies per escriure'ns sobre «{subject}».\n\n{sender}",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
25
database/factories/TagFactory.php
Normal file
25
database/factories/TagFactory.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Tag;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<Tag>
|
||||||
|
*/
|
||||||
|
class TagFactory extends Factory
|
||||||
|
{
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$name = $this->faker->unique()->word();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => ['es' => $name, 'en' => $name, 'ca' => $name],
|
||||||
|
'slug' => ['es' => Str::slug($name), 'en' => Str::slug($name), 'ca' => Str::slug($name)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
database/factories/WebhookFactory.php
Normal file
27
database/factories/WebhookFactory.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Webhook;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<Webhook>
|
||||||
|
*/
|
||||||
|
class WebhookFactory extends Factory
|
||||||
|
{
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $this->faker->words(2, true),
|
||||||
|
'url' => 'https://hooks.example.org/'.Str::random(8),
|
||||||
|
'secret' => Str::random(40),
|
||||||
|
'events' => ['*'],
|
||||||
|
'include_personal_data' => false,
|
||||||
|
'is_active' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access tokens for machines: scripts, CI, an integration — anything that
|
||||||
|
* should reach the admin API without a person's password and with less than
|
||||||
|
* that person may do.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('api_tokens', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
|
||||||
|
// Whose permissions it borrows from. Deleting the person takes
|
||||||
|
// their tokens with them.
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('name');
|
||||||
|
|
||||||
|
// Only the hash is kept: the token is shown once, when it is made.
|
||||||
|
$table->string('token_hash', 64)->unique();
|
||||||
|
|
||||||
|
// The first characters, to tell tokens apart in a list.
|
||||||
|
$table->string('prefix', 12);
|
||||||
|
|
||||||
|
$table->jsonb('abilities');
|
||||||
|
$table->timestamp('expires_at');
|
||||||
|
$table->timestamp('last_used_at')->nullable();
|
||||||
|
$table->timestamp('revoked_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('api_tokens');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outgoing webhooks: telling another system that something happened here —
|
||||||
|
* a submission arrived, a page went live — instead of it having to ask.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('webhooks', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('url', 2048);
|
||||||
|
|
||||||
|
// Signs every delivery. Encrypted at rest: it is a credential.
|
||||||
|
$table->text('secret');
|
||||||
|
|
||||||
|
$table->jsonb('events');
|
||||||
|
|
||||||
|
// Submissions carry names, addresses and messages from people
|
||||||
|
// outside the collective; sending them elsewhere is opted into.
|
||||||
|
$table->boolean('include_personal_data')->default(false);
|
||||||
|
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->unsignedSmallInteger('consecutive_failures')->default(0);
|
||||||
|
$table->timestamp('last_delivered_at')->nullable();
|
||||||
|
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('webhook_deliveries', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('webhook_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->uuid('uuid')->unique();
|
||||||
|
$table->string('event', 60);
|
||||||
|
$table->jsonb('payload');
|
||||||
|
$table->string('status', 20)->default('pending');
|
||||||
|
$table->unsignedSmallInteger('attempts')->default(0);
|
||||||
|
$table->unsignedSmallInteger('response_status')->nullable();
|
||||||
|
$table->text('error')->nullable();
|
||||||
|
$table->timestamp('delivered_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['webhook_id', 'created_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('webhook_deliveries');
|
||||||
|
Schema::dropIfExists('webhooks');
|
||||||
|
}
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user