Compare commits

..

37 Commits

Author SHA1 Message Date
9f970808ca Tag blog posts and sign them with a member
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Tags are named in every language and made from the page form; the byline is
the member who signs a post on the site. The public API sends both, filters
posts by tag and lists the tags in use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:01:18 +02:00
6fc600e6a1 Add a translation assistant to the panel
Each field's original sits beside the field to write, and a translation that
fell behind shows what the original said when it was translated and what it
says now, word by word.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:53:31 +02:00
76d022053b Answer the inbox from the panel, with templates
A reply starts from a template filled in the language the person wrote in,
is sent by the application when it has a mailer that delivers or handed to
the replier's mail program otherwise, and is recorded on the submission.
The same is available through the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:45:47 +02:00
697d333ad4 Add a statistics page to the panel
The most read, reads per language with a daily chart, and published content
nobody opened, each compared with the period before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:19:19 +02:00
8d0f81f9c5 Add an editorial calendar to the panel
A month of publications, scheduled dates, content coming off the site, and
calls opening and closing, each linked to its form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:17:46 +02:00
773d3901c6 Warn about calls closing and content coming off the site
A dashboard widget lists what expires in the next two weeks, and a daily
command reminds whoever edits it a week and a day ahead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:16:24 +02:00
366568b793 Duplicate records as drafts, and preview them from the form
The copy keeps the content, stack and team, but is unpublished, out of any
review, and gets a title marked as a copy and a free slug. It replaces
Filament's replicate action, which left the copy without a slug and still
published. The same copy is available through the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:14:51 +02:00
70521d4bc9 Tell scheduling apart from unpublishing in the history
Giving a record a future date was recorded as "unpublished", and announced
to webhooks as such. The history now compares before and after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:09:03 +02:00
1fe1e67773 Keep an audit log of who did what, and how they came in
Written from model events, spatie's role events and panel sign-ins, only for
what a person did, with secrets masked; pruned after a year.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:08:01 +02:00
9bdbf834f6 Send signed outgoing webhooks
Fed by the records themselves, delivered only to public HTTPS addresses
checked at every attempt, retried, and switched off after repeated failures.
A submitter's details travel only to webhooks that opted into them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:42:39 +02:00
f93775e549 Issue machine tokens with limited permissions and an expiry
A token acts as the person who made it, within the permissions it was given
and those they still hold, checked on every request. Permission checks now go
through one gate rule, since spatie's answered first and never asked the
token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:21:37 +02:00
0119004f2c Run batches and import JSON or CSV through the API
Each operation is dispatched as a request of its own, so a batch is held to
the same validation and permissions as the calls it contains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:58:36 +02:00
6b3517743a Search the public site through the API
Full-text on Postgres, stemmed in the language searched; word matching
elsewhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:53:34 +02:00
68c5bd4274 Report site health and pending translations through the API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:32:45 +02:00
5b46436641 Review, publish, preview and undo changes through the API
Revisions now call publishing only what publishes: a project's status is
whether the work is active, and a service is published by its flag.

The fake identity server answers by token, so a test can sign in a second
person.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:29:48 +02:00
4c4a2b3299 Browse and clean up the media library through the API
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:55:03 +02:00
8d0f2e3639 Manage redirects, missing URLs, repositories and settings through the API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:51:00 +02:00
4c65604f04 Manage calls and the inbox through the API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:42:59 +02:00
b02464a7ed Manage clients, testimonials and talks through the API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:38:58 +02:00
96747abdc3 Keep technology logos and images inside text safe from pruning
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Media::referencedPaths() is what `content:prune --media` asks before
deleting a file, and it did not know about two kinds of image a page
shows:

- A technology's logo. Every logo on the radar looked orphaned, and one
  prune would have deleted all of them.
- Images inside rich text — a page's body, a project's description,
  decisions and postmortem — whether attached in the panel's editor or
  uploaded through the API and written in as <img src>. They are now
  found in the HTML, in every language, bin included.

The page body's editor also states where attachments go — the media
disk, pages/content, public — instead of relying on Filament's
defaults: the HTML reaches the frontend as it is, and a private file
would be a broken image there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:09:25 +02:00
3daa40570b Accept the option picked in selects of translatable records
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Saving a page with a category failed with "El campo categoría no está
en la lista de valores permitidos" — for every category, not only the
one being tried. Filament builds such a list through a query, which
handles `->relationship('category', 'name->es')`, but validates a choice
by reading the title back off the record as a property,
`$record->{'name->es'}`, which does not exist. No label, so every
option was refused.

The same pattern was in seven more places: a call's, a testimonial's,
a talk's and a repository's project, a testimonial's service, and the
page and application filters. TranslatedOptionLabel gives Filament the
record's own translation instead, in the language of whoever uses the
panel; the JSON path stays for search and ordering.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:21:43 +02:00
82ef2b38dd Manage projects, members and services through the API
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
/api/v1/projects, /members and /services: create, list, show, update,
delete and restore, with the panel's own rules — so the MCP server can
load the collective's work, people and offer without anyone retyping
them into forms. The MCP server picks the tools up from openapi.yaml.

- Relations travel as ids and are replaced whole when sent, left alone
  when not: a project's technology_ids, service_ids and members with
  each one's role; a member's technology_ids; a service's
  technology_ids and project_ids. Record and relations are saved in
  one transaction.
- Publishing stays a permission of its own. Without projects.publish
  or services.publish, published_at, unpublish_at and is_published are
  refused — the fields the panel disables for the same people.
- MemberPolicy still lets someone edit their own profile.
- Uploads accept `for: projects` and `for: members`, landing where the
  panel's own upload fields put them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:07:20 +02:00
6b67c7afd4 Give the test suite the memory it now needs
The full run died with "Premature end of PHP process" on machines whose
php.ini keeps PHP's usual 128M: no single test needs more, but the
panel tests boot Filament again and again in one process, and the
suite outgrew it. Always in a panel test, which is why it looked like
one of them was flaky. Stated in phpunit.xml so it runs the same
everywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:07:19 +02:00
7e672a5e62 Serve uploaded SVGs under their CSP, and identify the image fetcher
The Content-Security-Policy meant for stored SVGs never applied: in
Caddy a `*` in the middle of a path does not cross a `/`, so
`path /storage/*.svg` only matched files directly under /storage, and
every upload lives in a directory below it. Two matchers, which Caddy
ANDs, cover any depth.

RemoteImage fetched with Guzzle's anonymous default User-Agent, which
Wikimedia answers with 403. It now sends `<app>/1.0 (+<app url>; media
fetcher)`, the identification such hosts ask of automated clients.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:43:59 +02:00
ac546eba2b Manage technologies through the API, with SVG logos
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
/api/v1/technologies is a full CRUD with the panel's own rules: name and
slug are plain strings, the prose is translatable, `radar_ring: null`
takes one off the radar, and TechnologyPolicy still refuses to delete
one a project, member or call uses. The MCP server picks the tools up
from openapi.yaml on its own.

A logo is an SVG, so POST /v1/media now accepts one — sanitized with
enshrined/svg-sanitize (scripts, event handlers and remote references
removed), and served by Caddy under a Content-Security-Policy that
would stop a script regardless. `for` (pages or technologies) says what
an upload is for, which picks the directory the panel uses for it and
the permission it takes.

Creating a technology without a category answered 500: the column's
default never reached the model, so the response read a null enum. The
controller now states the default, as the panel does.

Beside a file, the MCP upload tool no longer demands the JSON body's
`url`: the body then carries the upload's other fields, such as `for`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:49:00 +02:00
a49816eb46 Upload images through the API, and from the MCP server
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
POST /api/v1/media stores an image where the panel keeps a page's and
answers with the path to send as `featured_image`. Without it the MCP
server could create a page but never give it a picture. The file goes
through Media::for(), so it is processed, served and pruned like one
uploaded in the panel.

Two ways in, one per request: a multipart `file`, or JSON with a `url`
the API downloads itself. JPEG, PNG, WebP, GIF or AVIF only, decided
from the bytes; no SVG, which is a document that can carry script.

The URL is fetched from inside the stack's network, so RemoteImage
guards it: HTTPS only, every resolved address must be public, the
connection is pinned to the checked address with CURLOPT_RESOLVE so a
changing DNS answer cannot swap in a private one, redirects are not
followed, and the body is read to a ceiling whatever its headers say.

Uploading needs permission to write pages. MediaPolicy::create stays
false, so the panel gains no free-standing "new file" button.

In the MCP server a multipart binary field becomes `<field>_path` over
stdio: the server reads the file from the caller's disk and sends it
as multipart, instead of the model pushing base64 through its context.
Over HTTP the server's disk is not the caller's, so only `body.url` is
offered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:16:33 +02:00
daf6098203 Load a page's image record in the admin API
PageResource only fills `featured_image_media` from the `featuredMedia`
relation when it is loaded, and PageController never loaded it, so every
admin response carried the image's URL with no dimensions, srcset or
placeholder even when the processed record existed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:16:31 +02:00
748b485c9b Add an MCP server for the API, deployable next to the application
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
mcp/ exposes both APIs as MCP tools, one per operation in openapi.yaml,
named after the Laravel route each lands on (public_projects_index,
admin_pages_update, ...). Nothing is written by hand: the tools are
derived from the document the target application serves, which the
contract tests already hold to the truth.

Two transports over one core:

- stdio (`node mcp/server.mjs`), registered in .mcp.json, signing in
  with credentials from the environment and refreshing the token.
- Streamable HTTP (`--http`), stateless, deployed as the landing-mcp
  service. It holds no credentials: each request brings its caller's
  Pireon Basic credentials or bearer token, so the API applies that
  person's permissions. Sessions live in memory keyed by a hash,
  refused credentials are remembered for a minute, and the spec is
  reloaded every minute so new endpoints appear without a redeploy.

Left out on purpose: the token exchange, telemetry, and the contact and
apply forms, which notify real people. The preview secret is never sent
for anonymous callers, and is unset in the deployment by default.

A CI job runs the Node tests, including the HTTP mode end to end
against a fake application.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:58:14 +02:00
5ca084bd4a Throttle the token endpoint per account and address, not per address
POST /api/v1/auth/token was limited to 6 requests a minute per IP. The
deployed MCP server signs every user in from a single container, so
under a per-IP budget one person's mistyped password, or a stranger's
guesses, would lock every other user out of it.

The named `auth-token` limiter keys on the lower-cased username (or the
refresh token) plus the address, still 6 a minute, with a 30-a-minute
ceiling per address to bound how many accounts one client can try.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:58:13 +02:00
5558db6d56 Rename the app service to landing-app to avoid a Dokploy network collision
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Compose aliases a service to its own name on every network it joins,
including the shared external dokploy-network. A service named plain
"app" collided with another project's equally generic "app" on that
same network, so its containers started resolving "app" to this one
instead — breaking oauth-iam's PHP-FPM upstream whenever both stacks
were up together.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 00:09:00 +02:00
1e925a2b07 add port
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
2026-08-22 23:47:24 +02:00
080c4238e8 remove port
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
2026-08-22 23:43:43 +02:00
ffde884f5b fix docker
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
2026-08-22 23:27:07 +02:00
2a58833190 fix track
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
2026-08-22 23:15:23 +02:00
c30863ab9b fix panel
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
2026-08-22 22:48:56 +02:00
2cca79cf04 fix docker
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
2026-08-22 22:30:18 +02:00
033cec6573 fix docker
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
2026-08-22 22:03:30 +02:00
228 changed files with 21731 additions and 278 deletions

View File

@@ -20,3 +20,6 @@ database/*.sqlite-journal
.phpunit.result.cache
docs/*.pdf
public/storage
# Runs on a developer machine, next to Claude Code, never in the image.
mcp

View File

@@ -2,10 +2,17 @@
# Production environment for the Docker stack.
#
# cp .env.production.example .env.production
# docker compose run --rm --no-deps app php artisan key:generate --show
# # fill in at least DB_PASSWORD below — docker-compose.yml refuses to
# # interpolate at all without it, even for the next command
# docker compose --env-file .env.production run --rm --no-deps landing-app php artisan key:generate --show
#
# Everything the containers need is here; docker-compose.yml sets the database,
# cache, session and queue drivers itself so they cannot drift.
#
# This file only matters for a manual deploy. A platform that injects its own
# "Environment Variables" (Dokploy, Coolify, ...) exports them as real process
# environment variables instead, which docker-compose.yml's `${VAR}`
# interpolation reads directly — no file needed on the server at all.
###############################################################################
APP_NAME="Pireon Admin"
@@ -116,3 +123,18 @@ MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM_ADDRESS="admin@example.org"
MAIL_FROM_NAME="${APP_NAME}"
###############################################################################
# MCP server (landing-mcp)
#
# Holds no credentials: every caller signs in with their own. Its domain is
# added in Dokploy's "Domains" tab, pointing at port 3000.
###############################################################################
# "true" refuses callers without credentials instead of offering them the
# public API — which is already public, so the default is to allow it.
LANDING_MCP_REQUIRE_AUTH=false
# Empty on purpose. Set to SITE_PREVIEW_TOKEN's value and anyone who can sign
# in sees every draft through the public tools, whatever their role.
LANDING_MCP_PREVIEW_TOKEN=

View File

@@ -79,3 +79,24 @@ jobs:
- uses: actions/checkout@v4
- run: docker compose -f docker-compose.test.yml run --rm test
# The MCP server derives its tools from openapi.yaml, so a change to the
# document can break it without touching a line under mcp/ — two operations
# that map to the same tool name, say. Cheap enough to run on every push.
mcp:
runs-on: ubuntu-latest
defaults:
run:
working-directory: mcp
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: mcp/package-lock.json
- run: npm ci
- run: npm test

1
.gitignore vendored
View File

@@ -27,3 +27,4 @@ _ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
/mcp/node_modules

15
.mcp.json Normal file
View File

@@ -0,0 +1,15 @@
{
"mcpServers": {
"landing": {
"command": "node",
"args": ["mcp/server.mjs"],
"env": {
"LANDING_URL": "${LANDING_URL:-http://localhost:8000}",
"LANDING_USERNAME": "${LANDING_USERNAME:-}",
"LANDING_PASSWORD": "${LANDING_PASSWORD:-}",
"LANDING_TOKEN": "${LANDING_TOKEN:-}",
"LANDING_PREVIEW_TOKEN": "${LANDING_PREVIEW_TOKEN:-}"
}
}
}
}

View File

@@ -20,13 +20,13 @@ WORKDIR /app
# sideways staying sideways. pcntl lets the queue worker shut down cleanly
# instead of being killed mid-job.
RUN install-php-extensions \
pdo_pgsql \
gd \
exif \
intl \
zip \
pcntl \
opcache \
pdo_pgsql \
gd \
exif \
intl \
zip \
pcntl \
opcache \
&& apt-get update \
&& apt-get install -y --no-install-recommends postgresql-client \
&& rm -rf /var/lib/apt/lists/*
@@ -49,11 +49,11 @@ COPY composer.json composer.lock ./
# application cannot boot before its code is here. They are run explicitly in
# the runtime stage instead, where everything exists.
RUN composer install \
--no-dev \
--no-scripts \
--no-autoloader \
--prefer-dist \
--no-interaction
--no-dev \
--no-scripts \
--no-autoloader \
--prefer-dist \
--no-interaction
COPY . .
@@ -93,7 +93,7 @@ RUN touch .env \
FROM base AS runtime
LABEL org.opencontainers.image.title="Pireon Admin" \
org.opencontainers.image.description="Admin panel and REST API for the collective"
org.opencontainers.image.description="Admin panel and REST API for the collective"
COPY docker/Caddyfile /etc/caddy/Caddyfile
@@ -113,9 +113,9 @@ RUN php artisan package:discover --ansi \
RUN chown -R www-data:www-data storage bootstrap/cache
ENV SERVER_NAME=:80
ENV SERVER_NAME=:8080
EXPOSE 80
EXPOSE 8080
ENTRYPOINT ["entrypoint"]

450
README.md
View File

@@ -390,14 +390,52 @@ Filtros para las cuatro preguntas que no se pueden responder en ningún otro
sitio: qué está **sin procesar**, qué **falló**, qué **no usa nadie** (lo mismo
que se llevaría `content:prune --media`) y qué **ya no está en disco**.
No se sube desde ahí: los archivos llegan al subirlos en una ficha. Y **no se
puede borrar uno que algo esté usando** — dejaría una imagen rota en la web, que
No se sube desde ahí: los archivos llegan al subirlos en una ficha (o por la
API, abajo). Y **no se puede borrar uno que algo esté usando** — dejaría una imagen rota en la web, que
es justo de lo que se queja la página de salud. El botón solo aparece en los
huérfanos.
«Reprocesar todo» es la respuesta a «he cambiado los anchos, ¿y ahora?»: la
tabla es derivada, así que reconstruirla siempre es seguro.
### Subir por la API
`POST /api/v1/media` es el campo de subida de una ficha, sin la ficha: guarda la
imagen donde el panel pone las de ese tipo de ficha y contesta con el `path` que
se manda como `featured_image` de una página o `logo` de una tecnología. `for`
dice para qué es (`pages`, por defecto, `technologies`, `projects`, `members`,
`clients` o `testimonials`), y con ello la carpeta y el permiso que hace falta. Existe para el [MCP](#servidor-mcp), que sin él
podía crear una página pero nunca ponerle una foto. Pasa por `Media::for()`,
así que se procesa, se sirve y se poda igual que una subida desde el panel.
Dos formas de entrada, una por petición: **multipart** con `file`, o **JSON**
con una `url` que la API descarga ella misma. JPEG, PNG, WebP, GIF, AVIF y SVG,
decidido por los bytes y no por el nombre ni el tipo declarado. 20 MB como mucho
(`MEDIA_UPLOAD_MAX_KB`).
**El SVG entra, pero nunca tal cual**: es lo que es un logo, y también un
documento que puede llevar script servido desde este dominio. Pasa antes por
[`enshrined/svg-sanitize`](https://github.com/darylldoyle/svg-sanitizer), que
quita scripts, manejadores de eventos y referencias a cualquier cosa fuera del
fichero; y Caddy sirve los `/storage/*.svg` con una `Content-Security-Policy`
que pararía un script igualmente si se colara.
**La URL es la parte delicada**, porque la petición la hace este servidor desde
dentro de la red del stack: sin control, sería una forma de pedirle
`http://postgres:5432` o el servicio de metadatos de la nube.
[`RemoteImage`](app/Services/Media/RemoteImage.php) exige HTTPS, resuelve el
dominio y **rechaza si una sola de sus direcciones no es pública** (privadas,
reservadas, loopback, link-local, 100.64/10, IPv4 mapeada en IPv6), **fija la
conexión a la dirección comprobada** con `CURLOPT_RESOLVE` para que un DNS que
cambie entre la comprobación y la petición no cuele otra, **no sigue
redirecciones** y deja de leer al pasar del límite, diga lo que diga
`Content-Length`.
El permiso es escribir el tipo de ficha de `for` (`pages.create` o
`pages.update`, `technologies.create` o…), no ninguno de `media.*`: esos siempre describieron cuidar ficheros que ya existen, y
`MediaPolicy::create` sigue siendo `false` para que el panel no gane un botón de
«nuevo fichero» suelto.
### Galerías
Un proyecto tiene además una galería —capturas, el antes y el después— que se
@@ -625,6 +663,22 @@ secreto viaje sin acabar en el navegador de cualquiera. Un frontend que
renderizara en cliente necesitaría otra cosa —un token de preview por sesión y
con caducidad—, no este secreto compartido.
## Etiquetas y firma del blog
Las entradas del blog (páginas de la categoría `blog`) llevan **etiquetas**
—«angular», «accesibilidad»— con nombre y slug en cada idioma, que se eligen en
el formulario de la página y se crean desde ahí mismo si no existen (Contenido →
Etiquetas para ordenarlas; las gestiona quien gestiona las categorías). Y una
**firma**: el miembro que la firma en la web, con enlace a su perfil, que no
tiene por qué ser la cuenta que la escribió en el panel (`author_id`, que se
queda dentro).
La API pública manda `tags` (solo las que tienen nombre en ese idioma) y
`byline` (solo si el miembro tiene perfil público), acepta `?tag=` en
`/public/v1/pages` y tiene `/public/v1/tags` con cuántas entradas publicadas
lleva cada una. La de administración, `tag_ids` y `byline_member_id` en las
páginas y `/api/v1/tags`.
## La vitrina: servicios, clientes, testimonios y charlas
Cuatro tipos de contenido con los que se compone una landing o un portfolio.
@@ -903,6 +957,22 @@ en tres idiomas: si llegaron juntos, ninguno va por detrás de otro.
El cálculo se cachea contra el `updated_at` del registro, así que se rehace en
cuanto algo cambia y ni un minuto antes.
### El asistente de traducción
Contenido → Traducciones lista lo que falta escribir en un idioma (azul) y lo
que se quedó atrás (ámbar, con desde cuándo), con un botón por idioma. Desde la
edición de una página, proyecto, servicio o convocatoria, «Traducir» lleva al
mismo sitio.
Al abrir uno, cada campo traducible sale **con el original al lado** y el campo
a escribir enfrente (texto enriquecido donde lo es). Y si la traducción se quedó
atrás, ese campo dice desde cuándo y enseña **qué cambió en el original desde
que se tradujo**, palabra a palabra: lo que se quitó tachado y lo que entró
marcado. El texto «de entonces» sale del historial, como la fecha. Así quien
traduce rehace la frase que cambió en vez de releerlo todo. Solo se guarda el
idioma que se está traduciendo, y el slug se deja al formulario de la ficha:
una dirección no es una traducción.
## Revisión
`pages.publish` lleva separado desde el principio: **poner algo en la web es un
@@ -1118,9 +1188,58 @@ horas y **solo abre ese registro**. El `SITE_PREVIEW_TOKEN` compartido sigue
funcionando, pero es un secreto único que hay que pegar en un curl; esto se
puede mandar por chat sin dar acceso a todos los borradores del sitio.
**Duplicar** — en páginas, proyectos, servicios y convocatorias. Copia todo
menos el slug y las fechas, porque dos registros no pueden responder en la misma
URL.
**Duplicar** — en páginas, proyectos, servicios y convocatorias, en la tabla y
en el formulario, y por API (`POST /api/v1/{tipo}/{id}/duplicate`). La copia es
un **borrador**: mismo contenido, misma tecnología, mismo equipo con sus roles,
pero sin publicar, fuera de cualquier revisión, con el título marcado
«(copia)» en cada idioma y un slug libre (`hola-copia`, `hola-copia-2`…). Se
abre en cuanto se crea. Antes la copia salía sin slug —un registro sin
dirección, que la salud del sitio marcaba— y conservaba el estado: duplicar una
página publicada daba otra «publicada» sin URL.
**Responder al buzón** — en la ficha de cada solicitud, «Responder» abre un
mensaje con el asunto ya puesto y, si se quiere, una **plantilla** (Colectivo →
Plantillas de respuesta) rellenada en el idioma en que escribió esa persona:
`{name}`, `{subject}` y `{sender}` se sustituyen. Se edita y se envía. Si la
instalación tiene un mailer que entrega de verdad (no `log` ni `array`), sale
desde la aplicación; si no, se abre el programa de correo de quien responde con
el texto ya escrito. En los dos casos **queda registrada** en la ficha —quién,
cuándo, qué— para que la siguiente persona que la abra sepa qué se dijo, y una
solicitud nueva pasa a «contactada» (una ya aceptada o rechazada no cambia). El
botón de siempre, un correo en blanco, sigue al lado. Por API:
`GET /api/v1/applications/{id}/replies`, `POST /api/v1/applications/{id}/reply`
(con `template_id`, o `subject` y `body`) y `GET /api/v1/reply-templates`.
`MAIL_REPLY_TO_ADDRESS` decide a dónde vuelven las contestaciones.
**Calendario editorial** (Contenido → Calendario editorial) — un mes de un
vistazo: lo que se publicó o se publicará (programado), lo que sale de la web,
y las convocatorias que abren y cierran, cada cosa enlazada a su formulario. Las
fechas ya estaban, de una en una en cada ficha; juntas es donde se ve que tres
cosas salen el mismo lunes y nada en quince días. El mes va en la URL
(`?month=2026-10`) para poder mandarlo, y en el móvil pasa a una lista con solo
los días que tienen algo. Cada persona ve los tipos que puede listar.
**Estadísticas** (Contenido → Estadísticas) — de las lecturas que ya manda el
frontend (totales por día, registro e idioma; nada sobre la persona): lo más
leído, las lecturas de cada idioma con su parte del total y una gráfica diaria,
y lo que **está en la web y nadie abrió** en el periodo —o cuesta encontrarlo o
no compensa—. 7, 30 o 90 días, y cada cifra comparada con el periodo anterior
de la misma duración, porque «320 lecturas» dice poco y «320, desde 210» dice
si algo funcionó.
**Próximos vencimientos** — en el dashboard, las convocatorias que cierran y
el contenido que sale de la web solo (`unpublish_at`) en las próximas dos
semanas, a quien puede editarlos. Y `content:expiring`, cada día a las 8, avisa
en la campana del panel a esas mismas personas **una semana y un día antes**
(`site.expiry_reminders`): dos avisos por cosa, no uno cada mañana durante
quince días. Nada de esto es un error —las fechas se pusieron a propósito— y
por eso no está en la salud del sitio; pero una convocatoria que cierra el
viernes sin que nadie haya contestado al buzón se ve mejor con una semana de
margen.
**Vista previa en el formulario** — el mismo botón de vista previa está también
arriba de la edición de páginas, proyectos, servicios y convocatorias: se mira
en la web real sin salir de lo que se está escribiendo.
**Búsqueda global** — los doce recursos, desde la barra de arriba.
@@ -1225,13 +1344,206 @@ POST /api/v1/pages/{page}/restore
GET /api/v1/users PATCH para roles, is_active, locale
GET /api/v1/roles solo lectura
GET /api/v1/roles/catalogue catálogo de permisos agrupado
/api/v1/projects CRUD + restore; relaciones por ids:
technology_ids, service_ids, members[{id, role}]
/api/v1/members CRUD + restore; links (etiqueta → URL), technology_ids
/api/v1/services CRUD + restore; technology_ids, project_ids
/api/v1/technologies CRUD completo; filtros: category,
radar_ring (none = fuera del radar), search
/api/v1/clients CRUD + restore; filtros: is_public, search
/api/v1/testimonials CRUD + restore; filtros: is_public, client_id,
project_id, service_id, search
/api/v1/talks CRUD (sin papelera, como en el panel)
/api/v1/calls CRUD; technology_ids; filtros: status,
project_id, search
GET /api/v1/applications el buzón; filtros: status (sin él, sin spam),
kind, call_id, search, trashed
PATCH /api/v1/applications/{id} solo status y notes
DELETE /api/v1/applications/{id} + POST …/restore
/api/v1/redirects CRUD; from/to normalizados, únicos por idioma
GET /api/v1/missing-urls las 404 que avisó el frontend (pending por defecto)
PATCH /api/v1/missing-urls/{id} pending ⇄ ignored
POST /api/v1/missing-urls/{id}/redirect crear la redirección que la resuelve
/api/v1/repositories CRUD (solo lo que no viene del proveedor)
POST /api/v1/repositories/{id}/sync encola una sincronización (202)
GET /api/v1/settings ajustes; PATCH /settings/{clave} sustituye el valor
GET /api/v1/maintenance modo mantenimiento de la web pública
PUT /api/v1/maintenance {enabled, message, until}
POST /api/v1/media subir una imagen (fichero o URL)
GET /api/v1/media la biblioteca; filtros: for, mime, search,
pending, failed, orphans; cada una dice si
algo la usa (referenced)
DELETE /api/v1/media/{id} solo si nada la usa (409 si no), con sus versiones
POST /api/v1/media/{id}/reprocess volver a derivar versiones (cola)
```
Publicar sigue siendo un permiso aparte, como en el panel (donde esos campos
salen desactivados): sin `projects.publish` o `services.publish`, la API rechaza
`published_at`, `unpublish_at` e `is_published`, así que quien puede escribir
algo no puede sacarlo a la web solo. Las relaciones enviadas sustituyen a las
que había, y las no enviadas no se tocan; todo se guarda en una transacción.
### Historial, revisión y vista previa
```
GET /api/v1/{tipo}/{id}/revisions historial (páginas, categorías, proyectos,
miembros, servicios, clientes, testimonios,
charlas, tecnologías, convocatorias)
POST /api/v1/revisions/{id}/restore deshacer lo que cambió una revisión
GET /api/v1/reviews lo que espera mi revisión
GET /api/v1/{tipo}/{id}/reviews el hilo (pages, projects, services)
POST /api/v1/{tipo}/{id}/comments comentar
POST /api/v1/{tipo}/{id}/review pedir revisión {reviewer_id?, note?}
POST /api/v1/{tipo}/{id}/return devolver con motivo {note}
POST /api/v1/{tipo}/{id}/publish aprobar: ya, o programado con {at}
POST /api/v1/{tipo}/{id}/unpublish
POST /api/v1/{tipo}/{id}/preview enlaces de vista previa por idioma
GET /api/v1/health la pantalla de salud del sitio
GET /api/v1/translations/pending lo que falta traducir (missing) o se quedó
atrás (stale); filtros: locale, type, published
```
Lo mismo que los botones del panel, con las mismas reglas: pedir revisión es
para quien escribe sin `{tipo}.publish`, devolver y publicar son para quien lo
tiene, y deshacer una revisión que tocó la publicación también lo pide.
Programar una publicación responde la revisión en ese momento, no cuando llega
la fecha: la decisión ya está tomada. Los enlaces de vista previa son los del
panel —un registro, un idioma, unas horas— y [`AdminContent`](app/Support/AdminContent.php)
es la tabla de qué tipo admite cada cosa, la que leen las rutas, los
controladores y el historial.
El historial ahora llama «publicado» solo a lo que publica de verdad: cambiar
el `status` de un proyecto (activo, en pausa) salía como publicación, y activar
un servicio no.
Los ajustes siguen los permisos del panel: `settings.view` para leer,
`settings.update` para cambiar y `settings.maintenance` para `site.maintenance`
y `/v1/maintenance`. `content.version` lo escribe la aplicación y la API lo
rechaza: puesto a mano, el frontend serviría páginas viejas o las pediría de
nuevo para nada. Sincronizar un repositorio pide `repositories.sync`, porque
gasta un límite del proveedor que comparte toda la aplicación, y va a la cola
en vez de hacer esperar a quien llama.
`GET /v1/me` devuelve el array `permissions`: úsalo para construir la UI del
cliente en vez de comprobar nombres de rol.
Quien no tenga `pages.publish` solo ve páginas publicadas en el listado.
### Tokens para máquinas
```
GET /api/v1/tokens mis tokens (all=1 para un super_admin)
POST /api/v1/tokens {name, abilities, expires_at?} → el token, una vez
DELETE /api/v1/tokens/{id} revocar
```
Para scripts, CI o integraciones que no deberían usar la contraseña de nadie.
Una persona lo crea con los permisos que le da (`pages.update`, o `pages.*`
para todo lo de páginas) y nunca más de los que ella tiene; caduca a los 90
días si no se dice otra cosa, y como mucho al año. Se guarda solo su hash, y el
token en claro (`pmt_…`) sale una única vez, en la respuesta que lo crea.
Actúa como esa persona **dentro** de sus permisos, y se comprueba en cada
petición, así que quitarle un rol recorta sus tokens al momento; desactivarla
los apaga. El guard lo reconoce por el prefijo y **no lo manda nunca al
servidor de identidad**. Un token no puede crear ni revocar tokens: si no, uno
filtrado sería una forma de no caducar nunca.
Para que eso funcione, la comprobación de permisos es ahora una sola regla en
`AuthServiceProvider` y la de spatie está apagada
(`register_permission_check_method => false`): la de spatie respondía antes y
concedía el permiso sin preguntar al token.
### Webhooks salientes
```
/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
```
@@ -1332,6 +1644,107 @@ Va **commiteado** —quien lo necesita debería poder leerlo sin ejecutar nada
viajan como comentarios: la mitad de lo que sabe el `openapi.yaml` está en su
prosa, y un tipo a secas no la conserva.
## Servidor MCP
[`mcp/`](mcp/) es un servidor MCP (Node ≥ 20) que da a Claude —o a cualquier
cliente MCP— las dos APIs como herramientas: `public_projects_index`,
`admin_pages_update`, `admin_roles_catalogue`… Una por operación documentada,
con el nombre de la ruta de Laravel en la que acaba.
**No hay ninguna escrita a mano.** Lee el `openapi.yaml` que sirve la
aplicación a la que apunta y las genera de ahí, así que describe la API con la
que está hablando —local, staging o producción— y no la de este checkout. Por lo
mismo que los tipos: el documento lo sujetan los tests de contrato, y una
segunda descripción de la API escrita a mano no la sujetaría nada. La
introducción del documento (las dos APIs, idiomas, campos traducibles) viaja
como instrucciones del servidor.
### Desplegado (HTTP)
Va en el stack como un contenedor más, `landing-mcp`, que habla con
`landing-app` por la red interna. En Dokploy, añádele un dominio en la pestaña
«Domains» apuntando al puerto 3000; el endpoint es `https://<dominio>/mcp`
(Streamable HTTP, sin estado).
**No tiene credenciales propias.** Cada petición trae las de quien la hace, y
la API aplica los permisos de esa persona, igual que en el panel:
```bash
claude mcp add --transport http landing https://mcp.example.org/mcp \
--header "Authorization: Basic $(printf 'tu@email.com:contraseña' | base64)"
```
- **`Basic`** con el email y la contraseña de Pireon: el servidor hace login la
primera vez, guarda la sesión en memoria (indexada por un hash, nunca en
disco ni en logs), refresca el token antes de que caduque y la olvida tras
12 horas sin uso. Es lo recomendable, porque los tokens de Pireon caducan.
- **`Bearer`** con un token de Pireon: funciona hasta que caduca. Se valida
contra `GET /v1/me` y se revalida cada minuto, como hace la propia app.
- **Sin cabecera**: solo la API pública, que ya es pública.
`LANDING_MCP_REQUIRE_AUTH=true` lo cierra.
Unas credenciales rechazadas se recuerdan un minuto, sin volver a preguntar a
la app. Por eso mismo `POST /v1/auth/token` limita **por cuenta y dirección**
(6/min) y no solo por dirección (con un techo de 30/min por dirección): todos
los logins del MCP salen del mismo contenedor, y un límite por IP haría que la
contraseña mal escrita de uno —o los intentos de un extraño— dejaran fuera a
todos los demás.
El documento se relee cada minuto, así que un endpoint nuevo desplegado en la
app aparece como herramienta sin redesplegar el MCP; si la app está caída, se
sigue usando el último que cargó.
`LANDING_MCP_PREVIEW_TOKEN` está vacío a propósito: con el valor de
`SITE_PREVIEW_TOKEN`, cualquiera que pueda iniciar sesión ve todos los
borradores por las herramientas públicas, sea cual sea su rol. Nunca se manda
a quien llega sin credenciales.
### En local (stdio)
```bash
cd mcp && npm install
```
En este repo ya está registrado en [`.mcp.json`](.mcp.json), que lo lanza por
stdio con la configuración del entorno. Aquí sí las credenciales van en
variables, porque quien lo lanza es quien inicia sesión:
```
LANDING_URL raíz de la aplicación (por defecto http://localhost:8000)
LANDING_USERNAME credenciales de Pireon para la API de administración;
LANDING_PASSWORD el token se pide y se refresca solo
LANDING_TOKEN o un bearer token fijo en vez de credenciales —
lo natural es un token de máquina (pmt_…) con
solo los permisos que el MCP necesita
LANDING_PREVIEW_TOKEN opcional: las herramientas públicas ven borradores
LANDING_OPENAPI opcional: ruta o URL de la especificación
```
`node mcp/server.mjs --http` arranca el modo HTTP en local (en `127.0.0.1:3000`
salvo `LANDING_MCP_HOST`/`LANDING_MCP_PORT`), para probarlo sin Docker.
### En los dos modos
- **Sin credenciales solo aparece la mitad pública.** Ofrecer herramientas que
solo pueden fallar le cuesta al modelo una llamada cada una para descubrirlo.
- **Fuera a propósito**: `POST /v1/auth/token` (es cosa del cliente), la
telemetría (la manda el frontend con su propio secreto) y los dos formularios
del buzón — una llamada a `contact` o `apply` mete una fila en el buzón y
avisa a gente real.
- Las cabeceras no se exponen como parámetros: son secretos, y salen de la
configuración, no del modelo.
- **Subir imágenes** (`admin_media_store`): en local el modelo pasa una ruta de
tu disco (`file_path`) y el servidor la lee y la sube como multipart —en
base64 una foto normal serían cientos de miles de tokens—. **Desplegado no hay
`file_path`**: el servidor leería su propio disco a petición de cualquiera, así
que solo se ofrece `body.url`, que descarga la API con las protecciones de
[Subir por la API](#subir-por-la-api).
`npm test` (en `mcp/`, y en CI) genera las herramientas desde el
`openapi.yaml` de este repo —un cambio en el documento que haga chocar dos
nombres rompe el build aquí, no la sesión de alguien— y prueba el modo HTTP de
punta a punta contra una app simulada.
## Comandos
```bash
@@ -1347,6 +1760,7 @@ php artisan media:process # deriva lo que falte de las imáge
php artisan media:process --all # reprocesa todas
php artisan content:schedule # cierra convocatorias vencidas
php artisan content:expiring --dry-run # a quién se avisaría de lo que vence
php artisan content:prune --dry-run # qué se tiraría
php artisan content:prune --media # tira también imágenes huérfanas
@@ -1364,7 +1778,7 @@ php artisan site:maintenance --up
## Tests
```bash
php artisan test # 394 tests, 1128 aserciones
php artisan test # 391 tests, 1121 aserciones
```
En cada push y cada pull request, [`.github/workflows/tests.yml`](.github/workflows/tests.yml)
@@ -1458,7 +1872,7 @@ párrafo aparte sin relación programática con él.
## Estructura
```
docker-compose.yml stack de producción (app, worker, scheduler, postgres)
docker-compose.yml stack de producción (landing-app, worker, scheduler, landing-mcp, postgres)
docker-compose.test.yml la suite contra Postgres, desechable
Dockerfile imagen única para los tres contenedores PHP
docker/ Caddyfile, php.ini y entrypoint
@@ -1491,6 +1905,7 @@ config/site.php frontend público, preview, caché, rate
config/media.php disco, anchos y formatos de imagen
config/git.php proveedores de git y etiquetas de issues
public/openapi.yaml especificación de ambas APIs
mcp/ servidor MCP (stdio y HTTP) generado desde la anterior
```
## Despliegue con Docker
@@ -1499,16 +1914,29 @@ Cuatro contenedores y una base de datos:
```bash
cp .env.production.example .env.production
docker compose run --rm --no-deps app php artisan key:generate --show # pégalo en APP_KEY
# Ahora edita .env.production: al menos DB_PASSWORD tiene que tener un valor
# real, porque docker-compose.yml no arranca sin él — ni siquiera para el
# comando siguiente, que solo interpola el fichero entero antes de ejecutar
# nada. APP_KEY puede quedarse vacío por ahora, se rellena con este paso:
docker compose --env-file .env.production run --rm --no-deps landing-app php artisan key:generate --show # pégalo en APP_KEY
docker compose --env-file .env.production up -d --build
# el primer administrador, igual que en local
docker compose exec app php artisan user:role tu@email.com super_admin
docker compose exec landing-app php artisan user:role tu@email.com super_admin
```
Todas las variables de `docker-compose.yml` llegan por interpolación `${VAR}`,
no por `env_file:` — así que esto funciona igual con `--env-file`, con
variables ya exportadas en el shell, o con el panel de "Environment
Variables" de una plataforma como Dokploy o Coolify, que las inyecta como
variables de entorno reales del proceso que lanza `docker compose`. Ninguna
de las tres formas necesita que exista un fichero `.env.production` en el
servidor.
| Contenedor | Qué hace |
|---|---|
| `app` | FrankenPHP sirviendo el panel y la API |
| `landing-app` | FrankenPHP sirviendo el panel y la API |
| `worker` | procesa imágenes y manda los avisos del buzón |
| `scheduler` | cierra convocatorias, barre imágenes pendientes, sincroniza repos |
| `postgres` | la base de datos |
@@ -1587,7 +2015,7 @@ docker compose -f docker-compose.test.yml run --rm test
Levanta un Postgres en memoria, corre la suite entera contra él y lo tira. Es la
comprobación que convierte «debería funcionar» en «funciona», y conviene hacerla
antes del primer despliegue. **394 tests en verde contra Postgres 17.**
antes del primer despliegue. **391 tests en verde contra Postgres 17.**
Usa una etapa propia de la imagen: la de producción se construye con `--no-dev`
y ahí no está ni el runner de tests.

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Auth;
use App\Actions\Auth\SyncPireonUser;
use App\Models\ApiToken;
use App\Models\User;
use App\Services\Pireon\PireonClient;
use App\Services\Pireon\PireonException;
@@ -35,7 +36,16 @@ class PireonTokenGuard
{
$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;
}
@@ -52,6 +62,21 @@ class PireonTokenGuard
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
{
$key = 'pireon:introspect:'.hash('sha256', $token);

View 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;
}
}

View 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(),
];
}
}

View 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));
}
}

View 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).' %';
}
}

View 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)),
));
}
}

View File

@@ -51,6 +51,7 @@ class RevisionsRelationManager extends RelationManager
->color(fn (string $state) => match ($state) {
'published' => 'success',
'unpublished' => 'warning',
'scheduled' => 'info',
'created' => 'info',
'deleted', 'erased' => 'danger',
default => 'gray',
@@ -77,7 +78,7 @@ class RevisionsRelationManager extends RelationManager
->filters([
SelectFilter::make('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}")])
->all()),
])

View File

@@ -7,13 +7,19 @@ namespace App\Filament\Resources\Applications\Pages;
use App\Filament\Resources\Applications\ApplicationResource;
use App\Filament\Resources\Applications\Tables\ApplicationsTable;
use App\Models\Application;
use App\Models\ApplicationReply;
use App\Models\ReplyTemplate;
use App\Support\Inbox\Replier;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Support\Facades\Auth;
class ViewApplication extends ViewRecord
{
@@ -24,10 +30,67 @@ class ViewApplication extends ViewRecord
return [
// Answering is the point of the record, so the reply is the first
// 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')
->label(__('panel.applications.actions.reply'))
->icon('heroicon-o-envelope')
->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='
.rawurlencode(__('panel.applications.reply_subject', [
'subject' => $record->subject(),

View File

@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace App\Filament\Resources\Applications\Schemas;
use App\Models\Application;
use App\Models\ApplicationReply;
use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
@@ -69,6 +71,36 @@ class ApplicationInfolist
->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'))
->description(__('panel.applications.sections.internal_hint'))
->columns(4)

View File

@@ -6,6 +6,7 @@ namespace App\Filament\Resources\Applications\Tables;
use App\Enums\ApplicationKind;
use App\Enums\ApplicationStatus;
use App\Filament\Support\TranslatedOptionLabel;
use App\Models\Application;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
@@ -93,6 +94,7 @@ class ApplicationsTable
SelectFilter::make('call')
->label(__('panel.applications.fields.call'))
->relationship('call', 'title->'.config('locales.default'))
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
->searchable()
->preload(),

View File

@@ -10,8 +10,10 @@ use App\Filament\Resources\Calls\Pages\CreateCall;
use App\Filament\Resources\Calls\Pages\EditCall;
use App\Filament\Resources\Calls\Pages\ListCalls;
use App\Filament\Resources\Calls\RelationManagers\ApplicationsRelationManager;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction;
use App\Filament\Support\TranslatableTabs;
use App\Filament\Support\TranslatedOptionLabel;
use App\Models\Call;
use App\Rules\UniqueTranslatedSlug;
use BackedEnum;
@@ -19,7 +21,6 @@ use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ReplicateAction;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
@@ -141,6 +142,7 @@ class CallResource extends Resource
Select::make('project_id')
->label(__('panel.calls.fields.project'))
->relationship('project', 'title->'.config('locales.default'))
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
->searchable()
->preload()
->helperText(__('panel.calls.fields.project_hint')),
@@ -228,17 +230,9 @@ class CallResource extends Resource
->preload(),
])
->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'),
DuplicateAction::make(),
EditAction::make(),
DeleteAction::make(),

View File

@@ -6,6 +6,9 @@ namespace App\Filament\Resources\Calls\Pages;
use App\Filament\Concerns\WarnsOfConcurrentEditing;
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\Resources\Pages\EditRecord;
@@ -18,6 +21,10 @@ class EditCall extends EditRecord
protected function getHeaderActions(): array
{
return [
// Seeing it on the real site without leaving the form.
PreviewAction::for('call'),
DuplicateAction::make(),
TranslateAction::make(),
DeleteAction::make(),
];
}

View File

@@ -7,6 +7,9 @@ namespace App\Filament\Resources\Pages\Pages;
use App\Enums\PageStatus;
use App\Filament\Concerns\WarnsOfConcurrentEditing;
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\ForceDeleteAction;
use Filament\Actions\RestoreAction;
@@ -21,6 +24,10 @@ class EditPage extends EditRecord
protected function getHeaderActions(): array
{
return [
// Seeing it on the real site without leaving the form.
PreviewAction::for('page'),
DuplicateAction::make(),
TranslateAction::make(),
DeleteAction::make(),
ForceDeleteAction::make(),
RestoreAction::make(),

View File

@@ -7,7 +7,9 @@ namespace App\Filament\Resources\Pages\Schemas;
use App\Enums\PageStatus;
use App\Filament\Resources\Pages\PageResource;
use App\Filament\Support\TranslatableTabs;
use App\Filament\Support\TranslatedOptionLabel;
use App\Models\Page;
use App\Models\Tag;
use App\Rules\UniqueTranslatedSlug;
use App\Support\DuplicateTitles;
use Filament\Forms\Components\DateTimePicker;
@@ -74,6 +76,15 @@ class PageForm
RichEditor::make("body.{$locale}")
->label(__('panel.pages.fields.body'))
// Images in the text go where every other upload
// does, public: the HTML travels to the frontend
// as it is, with each image's URL written in, and
// a private file would be a broken image there.
// Media::referencedPaths() finds them in the body,
// so pruning never takes one a page still shows.
->fileAttachmentsDisk(config('media.disk'))
->fileAttachmentsDirectory('pages/content')
->fileAttachmentsVisibility('public')
->columnSpanFull(),
]),
]),
@@ -112,6 +123,40 @@ class PageForm
Select::make('category_id')
->label(__('panel.pages.fields.category'))
->relationship('category', 'name->'.config('locales.default'))
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('name'))
->searchable()
->preload(),
// What the post is about, for the blog to be read by
// subject. A missing one is made from here.
Select::make('tags')
->label(__('panel.tags.plural'))
->relationship('tags', 'name->'.config('locales.default'))
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('name'))
->multiple()
->searchable()
->preload()
->createOptionForm([
TextInput::make('name')
->label(__('panel.tags.fields.name'))
->required()
->maxLength(80),
])
->createOptionUsing(function (array $data): int {
$locale = (string) config('locales.default');
return Tag::query()->create([
'name' => [$locale => $data['name']],
'slug' => [$locale => Str::slug($data['name'])],
])->getKey();
})
->createOptionAction(fn ($action) => $action->authorize('create', Tag::class)),
// Who signs it on the site, with a link to their profile.
Select::make('byline_member_id')
->label(__('panel.pages.fields.byline'))
->helperText(__('panel.pages.fields.byline_hint'))
->relationship('byline', 'name')
->searchable()
->preload(),

View File

@@ -5,9 +5,11 @@ declare(strict_types=1);
namespace App\Filament\Resources\Pages\Tables;
use App\Enums\PageStatus;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction;
use App\Filament\Support\ReviewActions;
use App\Filament\Support\ReviewStatusColumn;
use App\Filament\Support\TranslatedOptionLabel;
use App\Filament\Support\TranslationStatusColumn;
use App\Filament\Support\ViewsColumn;
use App\Models\Page;
@@ -19,7 +21,6 @@ use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\ReplicateAction;
use Filament\Actions\RestoreBulkAction;
use Filament\Notifications\Notification;
use Filament\Tables\Columns\IconColumn;
@@ -127,6 +128,7 @@ class PagesTable
SelectFilter::make('category')
->label(__('panel.pages.fields.category'))
->relationship('category', 'name->'.config('locales.default'))
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('name'))
->searchable()
->preload(),
@@ -209,17 +211,8 @@ class PagesTable
&& Auth::user()?->can('publish', $record))
->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'),
DuplicateAction::make(),
EditAction::make(),
DeleteAction::make(),

View File

@@ -6,6 +6,9 @@ namespace App\Filament\Resources\Projects\Pages;
use App\Filament\Concerns\WarnsOfConcurrentEditing;
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\Resources\Pages\EditRecord;
@@ -18,6 +21,10 @@ class EditProject extends EditRecord
protected function getHeaderActions(): array
{
return [
// Seeing it on the real site without leaving the form.
PreviewAction::for('project'),
DuplicateAction::make(),
TranslateAction::make(),
DeleteAction::make(),
];
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\Projects\Tables;
use App\Enums\ProjectStatus;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction;
use App\Filament\Support\ReviewActions;
use App\Filament\Support\ReviewStatusColumn;
@@ -19,7 +20,6 @@ use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\ReplicateAction;
use Filament\Actions\RestoreBulkAction;
use Filament\Notifications\Notification;
use Filament\Tables\Columns\IconColumn;
@@ -197,17 +197,8 @@ class ProjectsTable
&& Auth::user()?->can('publish', $record))
->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'),
DuplicateAction::make(),
EditAction::make(),
DeleteAction::make(),

View File

@@ -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;
}

View File

@@ -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(),
];
}
}

View File

@@ -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(),
];
}
}

View 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'),
];
}
}

View File

@@ -9,6 +9,7 @@ use App\Filament\Resources\Repositories\Pages\CreateRepository;
use App\Filament\Resources\Repositories\Pages\EditRepository;
use App\Filament\Resources\Repositories\Pages\ListRepositories;
use App\Filament\Resources\Repositories\Tables\RepositoriesTable;
use App\Filament\Support\TranslatedOptionLabel;
use App\Models\Repository;
use BackedEnum;
use Filament\Forms\Components\Select;
@@ -81,6 +82,7 @@ class RepositoryResource extends Resource
Select::make('project_id')
->label(__('panel.repositories.fields.project'))
->relationship('project', 'title->'.config('locales.default'))
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
->searchable()
->preload(),

View File

@@ -6,6 +6,9 @@ namespace App\Filament\Resources\Services\Pages;
use App\Filament\Concerns\WarnsOfConcurrentEditing;
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\Resources\Pages\EditRecord;
@@ -18,6 +21,10 @@ class EditService extends EditRecord
protected function getHeaderActions(): array
{
return [
// Seeing it on the real site without leaving the form.
PreviewAction::for('service'),
DuplicateAction::make(),
TranslateAction::make(),
DeleteAction::make(),
];
}

View File

@@ -9,6 +9,7 @@ use App\Filament\RelationManagers\RevisionsRelationManager;
use App\Filament\Resources\Services\Pages\CreateService;
use App\Filament\Resources\Services\Pages\EditService;
use App\Filament\Resources\Services\Pages\ListServices;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction;
use App\Filament\Support\ReviewActions;
use App\Filament\Support\ReviewStatusColumn;
@@ -26,7 +27,6 @@ use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\ReplicateAction;
use Filament\Actions\RestoreBulkAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
@@ -323,17 +323,8 @@ class ServiceResource extends Resource
ReviewActions::request(),
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'),
DuplicateAction::make(),
EditAction::make(),
DeleteAction::make(),

View 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(),
];
}
}

View 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('/'),
];
}
}

View File

@@ -9,6 +9,7 @@ use App\Filament\Resources\Talks\Pages\CreateTalk;
use App\Filament\Resources\Talks\Pages\EditTalk;
use App\Filament\Resources\Talks\Pages\ListTalks;
use App\Filament\Support\TranslatableTabs;
use App\Filament\Support\TranslatedOptionLabel;
use App\Models\Talk;
use BackedEnum;
use Filament\Actions\BulkActionGroup;
@@ -126,6 +127,7 @@ class TalkResource extends Resource
Select::make('project_id')
->label(__('panel.talks.fields.project'))
->relationship('project', 'title->'.config('locales.default'))
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
->searchable()
->preload(),

View File

@@ -9,6 +9,7 @@ use App\Filament\Resources\Testimonials\Pages\CreateTestimonial;
use App\Filament\Resources\Testimonials\Pages\EditTestimonial;
use App\Filament\Resources\Testimonials\Pages\ListTestimonials;
use App\Filament\Support\TranslatableTabs;
use App\Filament\Support\TranslatedOptionLabel;
use App\Models\Testimonial;
use BackedEnum;
use Filament\Actions\BulkActionGroup;
@@ -137,12 +138,14 @@ class TestimonialResource extends Resource
Select::make('project_id')
->label(__('panel.testimonials.fields.project'))
->relationship('project', 'title->'.config('locales.default'))
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
->searchable()
->preload(),
Select::make('service_id')
->label(__('panel.testimonials.fields.service'))
->relationship('service', 'title->'.config('locales.default'))
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
->searchable()
->preload(),

View 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]));
});
}
}

View 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();
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Filament\Support;
use Closure;
use Illuminate\Database\Eloquent\Model;
/**
* The label of a relationship option whose title is translatable.
*
* `->relationship('category', 'name->es')` builds the list fine — that path
* goes through a query — but to validate a choice Filament reads the title
* back off the record as a property, `$record->{'name->es'}`, which does not
* exist. Every choice then came back without a label and was refused as "not
* in the list of allowed values", whichever one it was. Given this, Filament
* asks the record instead, in the language of whoever is using the panel.
*
* The JSON path stays in `relationship()`: it is still what the search box
* queries and the list is ordered by.
*/
final class TranslatedOptionLabel
{
public static function using(string $attribute): Closure
{
return fn (Model $record): ?string => $record->getTranslation($attribute, app()->getLocale());
}
}

View 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());
}
}

View File

@@ -6,7 +6,9 @@ namespace App\Http\Controllers\Api\PublicApi\V1;
use App\Http\Resources\PublicApi\PublicPageResource;
use App\Models\Page;
use App\Models\Tag;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -15,12 +17,15 @@ class PageController extends PublicController
public function index(Request $request): AnonymousResourceCollection
{
$pages = Page::query()
->with(['category', 'featuredMedia'])
->with(['category', 'featuredMedia', 'tags', 'byline.avatarMedia'])
->unless($this->isPreview($request), fn (Builder $q) => $q->published())
->when($request->filled('category'), fn (Builder $q) => $q
->whereHas('category', fn (Builder $c) => $c
->where('slug->'.app()->getLocale(), $request->string('category'))))
->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')
->paginate($this->perPage($request))
->withQueryString();
@@ -31,11 +36,38 @@ class PageController extends PublicController
public function show(Request $request, string $slug): PublicPageResource
{
$page = Page::query()
->with(['category', 'featuredMedia'])
->with(['category', 'featuredMedia', 'tags', 'byline.avatarMedia'])
->whereSlug($slug)
->unless($this->isPreview($request), fn (Builder $q) => $q->published())
->firstOrFail();
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]);
}
}

View 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()],
]);
}
}

View 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;
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\ApplicationStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\ApplicationRequest;
use App\Http\Resources\ApplicationResource;
use App\Models\Application;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* The inbox: what people sent from the public site.
*
* Nothing is created here — submissions arrive through the public API — and
* what can be changed is what the panel changes: the status and the private
* notes.
*/
class ApplicationController extends Controller
{
protected const WITH = ['call', 'handler'];
/**
* Filters: status, kind, call_id, search (name, e-mail or organisation), trashed.
*
* Spam stays out unless asked for by `status=spam`, as in the panel.
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Application::class);
$applications = Application::query()
->with(self::WITH)
->when(
$request->filled('status'),
fn ($q) => $q->where('status', $request->string('status')),
fn ($q) => $q->notSpam(),
)
->when($request->filled('kind'), fn ($q) => $q->where('kind', $request->string('kind')))
->when($request->filled('call_id'), fn ($q) => $q->where('call_id', $request->integer('call_id')))
->when($request->filled('search'), function ($q) use ($request): void {
$term = '%'.$request->string('search').'%';
$q->where(fn ($q) => $q
->where('name', 'like', $term)
->orWhere('email', 'like', $term)
->orWhere('organisation', 'like', $term));
})
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
->latest()
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return ApplicationResource::collection($applications);
}
public function show(Application $application): ApplicationResource
{
$this->authorize('view', $application);
return ApplicationResource::make($application->load(self::WITH));
}
public function update(ApplicationRequest $request, Application $application): ApplicationResource
{
$this->authorize('update', $application);
$data = $request->validated();
// Notes are outside the model's fillable list so the public form can
// never reach them; this is the one other place that writes them.
if (array_key_exists('notes', $data)) {
$application->forceFill(['notes' => $data['notes']])->save();
}
if (isset($data['status'])) {
$application->markAs(ApplicationStatus::from($data['status']), $request->user());
}
return ApplicationResource::make($application->load(self::WITH));
}
public function destroy(Application $application): JsonResponse
{
$this->authorize('delete', $application);
$application->delete();
return response()->json(status: 204);
}
public function restore(Application $application): ApplicationResource
{
$this->authorize('restore', $application);
$application->restore();
return ApplicationResource::make($application->load(self::WITH));
}
}

View 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(),
];
}
}

View 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);
}
}

View 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,
],
]);
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\CallRequest;
use App\Http\Resources\CallResource;
use App\Models\Call;
use App\Support\TranslatablePayload;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;
/**
* Open calls for contributors. Deleted for good, like the panel does.
*/
class CallController extends Controller
{
protected const WITH = ['technologies'];
/**
* Filters: status, project_id, search (title in the active language).
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Call::class);
$locale = app()->getLocale();
$calls = Call::query()
->with(self::WITH)
->withCount('applications')
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
->when($request->filled('project_id'), fn ($q) => $q->where('project_id', $request->integer('project_id')))
->when($request->filled('search'), fn ($q) => $q->where("title->{$locale}", 'like', '%'.$request->string('search').'%'))
->latest('id')
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return CallResource::collection($calls);
}
public function store(CallRequest $request): JsonResponse
{
$this->authorize('create', Call::class);
return CallResource::make($this->save(new Call, $request))->response()->setStatusCode(201);
}
public function show(Call $call): CallResource
{
$this->authorize('view', $call);
return CallResource::make($call->load(self::WITH)->loadCount('applications'));
}
public function update(CallRequest $request, Call $call): CallResource
{
$this->authorize('update', $call);
return CallResource::make($this->save($call, $request));
}
public function destroy(Call $call): JsonResponse
{
$this->authorize('delete', $call);
$call->delete();
return response()->json(status: 204);
}
protected function save(Call $call, CallRequest $request): Call
{
$data = $request->validated();
DB::transaction(function () use ($call, $data): void {
$call->fill(TranslatablePayload::merge(
$call,
collect($data)->except(['technology_ids'])->all(),
$call->translatable,
));
$call->save();
if (array_key_exists('technology_ids', $data)) {
$call->technologies()->sync($data['technology_ids']);
}
});
return $call->load(self::WITH)->loadCount('applications');
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\ClientRequest;
use App\Http\Resources\ClientResource;
use App\Models\Client;
use App\Support\TranslatablePayload;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* The organisations the work was for, as the panel edits them.
*/
class ClientController extends Controller
{
protected const WITH = ['logoMedia'];
/**
* Filters: is_public, search (name), trashed.
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Client::class);
$clients = Client::query()
->with(self::WITH)
->withCount('projects')
->when($request->has('is_public'), fn ($q) => $q->where('is_public', $request->boolean('is_public')))
->when($request->filled('search'), fn ($q) => $q->where('name', 'like', '%'.$request->string('search').'%'))
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
->orderBy('position')
->orderBy('name')
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return ClientResource::collection($clients);
}
public function store(ClientRequest $request): JsonResponse
{
$this->authorize('create', Client::class);
return ClientResource::make($this->save(new Client, $request))->response()->setStatusCode(201);
}
public function show(Client $client): ClientResource
{
$this->authorize('view', $client);
return ClientResource::make($client->load(self::WITH)->loadCount('projects'));
}
public function update(ClientRequest $request, Client $client): ClientResource
{
$this->authorize('update', $client);
return ClientResource::make($this->save($client, $request));
}
public function destroy(Client $client): JsonResponse
{
$this->authorize('delete', $client);
$client->delete();
return response()->json(status: 204);
}
public function restore(Client $client): ClientResource
{
$this->authorize('restore', $client);
$client->restore();
return ClientResource::make($client->load(self::WITH)->loadCount('projects'));
}
protected function save(Client $client, ClientRequest $request): Client
{
$client->fill(TranslatablePayload::merge($client, $request->validated(), $client->translatable));
$client->save();
return $client->load(self::WITH)->loadCount('projects');
}
}

View 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)));
}
}

View 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);
}
}

View 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()]);
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use App\Support\Maintenance;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* The public site's maintenance mode — the panel's maintenance screen, as two
* calls. Only the public site goes down; the panel and this API keep working.
*/
class MaintenanceController extends Controller
{
public function show(): JsonResponse
{
$this->authorize('settings.view');
return $this->state();
}
/**
* `enabled` switches it; `message` (language → text) and `until` are kept
* either way, so the wording can be prepared before the site goes down.
*/
public function update(Request $request): JsonResponse
{
$this->authorize('settings.maintenance');
$data = $request->validate([
'enabled' => ['required', 'boolean'],
'message' => ['sometimes', 'nullable', 'array'],
'message.*' => ['nullable', 'string', 'max:500'],
'until' => ['sometimes', 'nullable', 'date', 'after:now'],
]);
$message = array_key_exists('message', $data)
? array_filter((array) $data['message'], fn ($text) => filled($text))
: Maintenance::messages();
if ($data['enabled']) {
Maintenance::enable(
message: $message,
until: filled($data['until'] ?? null) ? CarbonImmutable::parse($data['until']) : null,
);
} else {
// Switching it off must not take the prepared wording with it.
Setting::set(Maintenance::KEY, [...Maintenance::state(), 'enabled' => false, 'until' => null, 'message' => $message]);
}
return $this->state();
}
protected function state(): JsonResponse
{
return response()->json(['data' => [
'enabled' => Maintenance::isEnabled(),
'message' => (object) Maintenance::messages(),
'until' => Maintenance::until()?->toIso8601String(),
'started_at' => Maintenance::startedAt()?->toIso8601String(),
]]);
}
}

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\MediaRequest;
use App\Http\Resources\MediaResource;
use App\Jobs\ProcessMedia;
use App\Models\Media;
use App\Services\Media\RemoteImage;
use enshrined\svgSanitize\Sanitizer;
use finfo;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/**
* The media library: every image on the media disk, and uploading new ones.
*
* `store` stores an image and answers with the path a record points at — what the
* panel's upload field does on a form, without the form. It exists for the MCP
* server, which otherwise could create a page but never give it a picture.
*
* The file lands where the panel puts a page's images and goes through
* `Media::for()`, so it is processed, served and eventually pruned exactly
* like one uploaded there.
*/
class MediaController extends Controller
{
/**
* Extension by detected type. Chosen from the bytes, never from the name
* the client sent.
*
* @var array<string, string>
*/
protected const EXTENSIONS = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
'image/gif' => 'gif',
'image/avif' => 'avif',
'image/svg+xml' => 'svg',
];
/**
* Filters: for (the kind of record it was uploaded for), mime, search
* (path), pending, failed, orphans (what `content:prune --media` would
* take away).
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Media::class);
// Worked out once for the page rather than once per file: it reads
// every record that can point at an image.
$referenced = array_flip(Media::referencedPaths());
$directory = config('media.upload.for.'.$request->string('for'));
$media = Media::query()
->when(is_string($directory), fn ($q) => $q->where('path', 'like', $directory.'/%'))
->when($request->filled('mime'), fn ($q) => $q->where('mime', $request->string('mime')))
->when($request->filled('search'), fn ($q) => $q->where('path', 'like', '%'.$request->string('search').'%'))
->when($request->boolean('pending'), fn ($q) => $q->pending())
->when($request->boolean('failed'), fn ($q) => $q->whereNotNull('error'))
->when($request->boolean('orphans'), fn ($q) => $q->whereNotIn('path', array_keys($referenced)))
->latest('id')
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
$media->getCollection()->each(fn (Media $item) => $item->setAttribute('referenced', isset($referenced[$item->path])));
return MediaResource::collection($media);
}
public function show(Media $media): MediaResource
{
$this->authorize('view', $media);
return MediaResource::make($media->setAttribute('referenced', $media->isReferenced()));
}
/**
* Only a file nothing points at: deleting one in use would put a broken
* image on the public site. Its derived versions go with it.
*/
public function destroy(Media $media): JsonResponse
{
// The permission first, so whether a file is in use is only told to
// someone who could have deleted it.
$this->authorize('media.delete');
abort_if($media->isReferenced(), 409, __('rules.media.in_use'));
$media->deleteDerived();
Storage::disk($media->disk)->delete($media->path);
$media->delete();
return response()->json(status: 204);
}
/**
* Derive the smaller versions and the placeholder again, on the queue.
*/
public function reprocess(Media $media): JsonResponse
{
$this->authorize('view', $media);
ProcessMedia::dispatch($media);
return MediaResource::make($media)->response()->setStatusCode(202);
}
public function store(MediaRequest $request, RemoteImage $remote): JsonResponse
{
$for = (string) $request->input('for', 'pages');
$this->authorize('upload', [Media::class, $for]);
$field = $request->hasFile('file') ? 'file' : 'url';
$contents = $field === 'file'
? (string) $request->file('file')->get()
: $remote->fetch((string) $request->string('url'));
$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($contents) ?: null;
if (! in_array($mime, (array) config('media.upload.mimes'), true)) {
$this->refuse($field);
}
if ($mime === 'image/svg+xml') {
$contents = $this->sanitize($contents) ?? $this->refuse($field);
// A vector has no pixel size; the processor copies it through.
$size = [null, null];
} else {
// The type is read from the magic bytes; this catches a file that
// has them but does not decode, which nobody could render either.
$size = @getimagesizefromstring($contents) ?: $this->refuse($field);
}
$path = config("media.upload.for.{$for}").'/'.Str::random(40).'.'.self::EXTENSIONS[$mime];
Storage::disk((string) config('media.disk'))->put($path, $contents);
$media = Media::for($path);
// What is already known, so the answer carries dimensions straight
// away rather than after the queue has run. The processor writes the
// same values, and the rest, when it does.
if (! $media->isProcessed()) {
$media->forceFill([
'mime' => $mime,
'size' => strlen($contents),
'width' => $size[0],
'height' => $size[1],
])->save();
}
return MediaResource::make($media->fresh())->response()->setStatusCode(201);
}
/**
* An SVG with what could run taken out: scripts, event handlers, and
* references to anything outside the file. Null when what is left is not
* an SVG at all.
*/
protected function sanitize(string $svg): ?string
{
$sanitizer = new Sanitizer;
$sanitizer->removeRemoteReferences(true);
$clean = $sanitizer->sanitize($svg);
return is_string($clean) && str_contains($clean, '<svg') ? $clean : null;
}
protected function refuse(string $field): never
{
throw ValidationException::withMessages([$field => [__('rules.media.not_an_image')]]);
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\MemberRequest;
use App\Http\Resources\MemberResource;
use App\Models\Member;
use App\Support\TranslatablePayload;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;
/**
* The people in the collective, as the panel edits them. MemberPolicy lets
* someone edit their own profile without `members.update`, as in the panel.
*/
class MemberController extends Controller
{
protected const WITH = ['avatarMedia', 'technologies', 'projects'];
/**
* Filters: search (name), is_public, trashed.
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Member::class);
$members = Member::query()
->with(self::WITH)
->when($request->filled('search'), fn ($q) => $q->where('name', 'like', '%'.$request->string('search').'%'))
->when($request->has('is_public'), fn ($q) => $q->where('is_public', $request->boolean('is_public')))
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
->orderBy('position')
->orderBy('name')
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return MemberResource::collection($members);
}
public function store(MemberRequest $request): JsonResponse
{
$this->authorize('create', Member::class);
return MemberResource::make($this->save(new Member, $request))->response()->setStatusCode(201);
}
public function show(Member $member): MemberResource
{
$this->authorize('view', $member);
return MemberResource::make($member->load(self::WITH));
}
public function update(MemberRequest $request, Member $member): MemberResource
{
$this->authorize('update', $member);
return MemberResource::make($this->save($member, $request));
}
public function destroy(Member $member): JsonResponse
{
$this->authorize('delete', $member);
$member->delete();
return response()->json(status: 204);
}
public function restore(Member $member): MemberResource
{
$this->authorize('restore', $member);
$member->restore();
return MemberResource::make($member->load(self::WITH));
}
protected function save(Member $member, MemberRequest $request): Member
{
$data = $request->validated();
DB::transaction(function () use ($member, $data): void {
$member->fill(TranslatablePayload::merge(
$member,
collect($data)->except('technology_ids')->all(),
$member->translatable,
));
$member->save();
if (array_key_exists('technology_ids', $data)) {
$member->technologies()->sync($data['technology_ids']);
}
});
return $member->load(self::WITH);
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\MissingUrlStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\MissingUrlRedirectRequest;
use App\Http\Resources\MissingUrlResource;
use App\Http\Resources\RedirectResource;
use App\Models\MissingUrl;
use App\Models\Redirect;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Validation\Rule;
/**
* Addresses the public site was asked for and could not find, reported by the
* frontend. What can be done with one is what the panel does: ignore it, or
* give it a destination.
*/
class MissingUrlController extends Controller
{
/**
* Filters: status (pending by default), locale, search (path).
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', MissingUrl::class);
$missing = MissingUrl::query()
->where('status', $request->string('status', MissingUrlStatus::Pending->value))
->when($request->filled('locale'), fn ($q) => $q->where('locale', $request->string('locale')))
->when($request->filled('search'), fn ($q) => $q->where('path', 'like', '%'.$request->string('search').'%'))
->orderByDesc('hits')
->orderByDesc('last_seen_at')
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return MissingUrlResource::collection($missing);
}
public function show(MissingUrl $missingUrl): MissingUrlResource
{
$this->authorize('view', $missingUrl);
return MissingUrlResource::make($missingUrl);
}
/**
* Only between pending and ignored: `resolved` is what writing a redirect
* makes it, and claiming it without one would be a lie the next sighting
* undoes anyway.
*/
public function update(Request $request, MissingUrl $missingUrl): MissingUrlResource
{
$this->authorize('update', $missingUrl);
$data = $request->validate([
'status' => ['required', Rule::in([MissingUrlStatus::Pending->value, MissingUrlStatus::Ignored->value])],
]);
$missingUrl->update($data);
return MissingUrlResource::make($missingUrl);
}
public function destroy(MissingUrl $missingUrl): JsonResponse
{
$this->authorize('delete', $missingUrl);
$missingUrl->delete();
return response()->json(status: 204);
}
/**
* Give the address a destination. The report is marked resolved by the
* redirect being created, as from anywhere else.
*/
public function redirect(MissingUrlRedirectRequest $request, MissingUrl $missingUrl): JsonResponse
{
$this->authorize('redirect', $missingUrl);
$redirect = Redirect::create([
'from' => $missingUrl->path,
'is_enabled' => true,
...$request->validated(),
])->refresh();
return RedirectResource::make($redirect)->response()->setStatusCode(201);
}
}

View File

@@ -17,6 +17,8 @@ use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class PageController extends Controller
{
protected const WITH = ['category', 'author', 'featuredMedia', 'tags'];
/**
* GET /api/v1/pages
*
@@ -30,7 +32,7 @@ class PageController extends Controller
$locale = app()->getLocale();
$pages = Page::query()
->with(['category', 'author'])
->with(self::WITH)
->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->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);
$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;
// A page created as published with no explicit date goes live now.
@@ -66,8 +68,9 @@ class PageController extends Controller
}
$page->save();
$this->syncTags($page, $request);
return PageResource::make($page->load(['category', 'author']))
return PageResource::make($page->load(self::WITH))
->response()
->setStatusCode(201);
}
@@ -76,22 +79,23 @@ class PageController extends Controller
{
$this->authorize('view', $page);
return PageResource::make($page->load(['category', 'author']));
return PageResource::make($page->load(self::WITH));
}
public function update(PageRequest $request, Page $page): PageResource
{
$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) {
$page->published_at = now();
}
$page->save();
$this->syncTags($page, $request);
return PageResource::make($page->load(['category', 'author']));
return PageResource::make($page->load(self::WITH));
}
public function destroy(Page $page): JsonResponse
@@ -109,7 +113,7 @@ class PageController extends Controller
$page->restore();
return PageResource::make($page->load(['category', 'author']));
return PageResource::make($page->load(self::WITH));
}
/**
@@ -135,4 +139,11 @@ class PageController extends Controller
$query->orderBy(in_array($column, $sortable, true) ? $column : 'created_at', $direction);
}
protected function syncTags(Page $page, PageRequest $request): void
{
if ($request->has('tag_ids')) {
$page->tags()->sync($request->validated('tag_ids') ?? []);
}
}
}

View 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);
}
}

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\ProjectRequest;
use App\Http\Resources\ProjectResource;
use App\Models\Project;
use App\Support\TranslatablePayload;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;
/**
* Projects, as the panel edits them — so the MCP server can load the
* collective's work without anyone copying it into a form by hand.
*/
class ProjectController extends Controller
{
/** Relations every answer carries, so a client can PATCH back what it read. */
protected const WITH = ['coverMedia', 'technologies', 'services', 'members'];
/**
* Filters: status, search (title in the active language), trashed.
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Project::class);
$locale = app()->getLocale();
$projects = Project::query()
->with(self::WITH)
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
->when($request->filled('search'), fn ($q) => $q->where("title->{$locale}", 'like', '%'.$request->string('search').'%'))
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
->orderBy('position')
->orderByDesc('created_at')
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return ProjectResource::collection($projects);
}
public function store(ProjectRequest $request): JsonResponse
{
$this->authorize('create', Project::class);
$project = $this->save(new Project, $request);
return ProjectResource::make($project)->response()->setStatusCode(201);
}
public function show(Project $project): ProjectResource
{
$this->authorize('view', $project);
return ProjectResource::make($project->load(self::WITH));
}
public function update(ProjectRequest $request, Project $project): ProjectResource
{
$this->authorize('update', $project);
return ProjectResource::make($this->save($project, $request));
}
public function destroy(Project $project): JsonResponse
{
$this->authorize('delete', $project);
$project->delete();
return response()->json(status: 204);
}
public function restore(Project $project): ProjectResource
{
$this->authorize('restore', $project);
$project->restore();
return ProjectResource::make($project->load(self::WITH));
}
/**
* The record and its relations in one transaction: a project that saved
* but lost its team halfway would be worse than one that did not save.
*/
protected function save(Project $project, ProjectRequest $request): Project
{
$data = $request->validated();
DB::transaction(function () use ($project, $data): void {
$project->fill(TranslatablePayload::merge(
$project,
collect($data)->except(['technology_ids', 'service_ids', 'members'])->all(),
$project->translatable,
));
$project->save();
if (array_key_exists('technology_ids', $data)) {
$project->technologies()->sync($data['technology_ids']);
}
if (array_key_exists('service_ids', $data)) {
$project->services()->sync($data['service_ids']);
}
if (array_key_exists('members', $data)) {
$project->members()->sync(collect($data['members'])->mapWithKeys(
fn (array $member) => [$member['id'] => ['role' => $member['role'] ?? null]],
)->all());
}
});
return $project->load(self::WITH);
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\RedirectRequest;
use App\Http\Resources\RedirectResource;
use App\Models\Redirect;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* Old addresses and where they go now.
*/
class RedirectController extends Controller
{
/**
* Filters: is_enabled, locale, automatic (left behind by a slug rename),
* search (either end).
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Redirect::class);
$redirects = Redirect::query()
->when($request->has('is_enabled'), fn ($q) => $q->where('is_enabled', $request->boolean('is_enabled')))
->when($request->filled('locale'), fn ($q) => $q->where('locale', $request->string('locale')))
->when($request->has('automatic'), fn ($q) => $request->boolean('automatic')
? $q->whereNotNull('source_type')
: $q->whereNull('source_type'))
->when($request->filled('search'), function ($q) use ($request): void {
$term = '%'.$request->string('search').'%';
$q->where(fn ($q) => $q->where('from', 'like', $term)->orWhere('to', 'like', $term));
})
->latest()
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return RedirectResource::collection($redirects);
}
public function store(RedirectRequest $request): JsonResponse
{
$this->authorize('create', Redirect::class);
return RedirectResource::make(Redirect::create($request->validated())->refresh())->response()->setStatusCode(201);
}
public function show(Redirect $redirect): RedirectResource
{
$this->authorize('view', $redirect);
return RedirectResource::make($redirect);
}
public function update(RedirectRequest $request, Redirect $redirect): RedirectResource
{
$this->authorize('update', $redirect);
$redirect->update($request->validated());
return RedirectResource::make($redirect);
}
public function destroy(Redirect $redirect): JsonResponse
{
$this->authorize('delete', $redirect);
$redirect->delete();
return response()->json(status: 204);
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\RepositoryRequest;
use App\Http\Resources\RepositoryResource;
use App\Jobs\SyncRepository;
use App\Models\Repository;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* The collective's git repositories. Only which repository, whose project and
* whether to show it are written here; everything else is what the provider
* last reported.
*/
class RepositoryController extends Controller
{
/**
* Filters: provider, project_id, show_publicly, failing, search (full name).
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Repository::class);
$repositories = Repository::query()
->when($request->filled('provider'), fn ($q) => $q->where('provider', $request->string('provider')))
->when($request->filled('project_id'), fn ($q) => $q->where('project_id', $request->integer('project_id')))
->when($request->has('show_publicly'), fn ($q) => $q->where('show_publicly', $request->boolean('show_publicly')))
->when($request->boolean('failing'), fn ($q) => $q->failing())
->when($request->filled('search'), fn ($q) => $q->where('full_name', 'like', '%'.$request->string('search').'%'))
->orderBy('full_name')
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return RepositoryResource::collection($repositories);
}
public function store(RepositoryRequest $request): JsonResponse
{
$this->authorize('create', Repository::class);
return RepositoryResource::make(Repository::create($request->validated())->refresh())->response()->setStatusCode(201);
}
public function show(Repository $repository): RepositoryResource
{
$this->authorize('view', $repository);
return RepositoryResource::make($repository);
}
public function update(RepositoryRequest $request, Repository $repository): RepositoryResource
{
$this->authorize('update', $repository);
$repository->update($request->validated());
return RepositoryResource::make($repository);
}
public function destroy(Repository $repository): JsonResponse
{
$this->authorize('delete', $repository);
$repository->delete();
return response()->json(status: 204);
}
/**
* Ask the provider again, on the queue: a sync spends a rate limit the
* whole application shares, and a caller should not wait on it.
*/
public function sync(Repository $repository): JsonResponse
{
$this->authorize('sync', $repository);
SyncRepository::dispatch($repository);
return RepositoryResource::make($repository)->response()->setStatusCode(202);
}
}

View 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,
];
}
}

View 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());
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\ServiceRequest;
use App\Http\Resources\ServiceResource;
use App\Models\Service;
use App\Support\TranslatablePayload;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;
/**
* What the collective offers, as the panel edits it.
*/
class ServiceController extends Controller
{
protected const WITH = ['technologies', 'projects'];
/**
* Filters: is_published, search (title in the active language), trashed.
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Service::class);
$locale = app()->getLocale();
$services = Service::query()
->with(self::WITH)
->when($request->has('is_published'), fn ($q) => $q->where('is_published', $request->boolean('is_published')))
->when($request->filled('search'), fn ($q) => $q->where("title->{$locale}", 'like', '%'.$request->string('search').'%'))
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
->orderBy('position')
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return ServiceResource::collection($services);
}
public function store(ServiceRequest $request): JsonResponse
{
$this->authorize('create', Service::class);
return ServiceResource::make($this->save(new Service, $request))->response()->setStatusCode(201);
}
public function show(Service $service): ServiceResource
{
$this->authorize('view', $service);
return ServiceResource::make($service->load(self::WITH));
}
public function update(ServiceRequest $request, Service $service): ServiceResource
{
$this->authorize('update', $service);
return ServiceResource::make($this->save($service, $request));
}
public function destroy(Service $service): JsonResponse
{
$this->authorize('delete', $service);
$service->delete();
return response()->json(status: 204);
}
public function restore(Service $service): ServiceResource
{
$this->authorize('restore', $service);
$service->restore();
return ServiceResource::make($service->load(self::WITH));
}
protected function save(Service $service, ServiceRequest $request): Service
{
$data = $request->validated();
DB::transaction(function () use ($service, $data): void {
$service->fill(TranslatablePayload::merge(
$service,
collect($data)->except(['technology_ids', 'project_ids'])->all(),
$service->translatable,
));
$service->save();
if (array_key_exists('technology_ids', $data)) {
$service->technologies()->sync($data['technology_ids']);
}
if (array_key_exists('project_ids', $data)) {
$service->projects()->sync($data['project_ids']);
}
});
return $service->load(self::WITH);
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\SettingResource;
use App\Models\Setting;
use App\Support\ContentVersion;
use App\Support\Maintenance;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* Runtime settings, as the panel's settings screen shows them: the ones that
* exist, each value as the JSON it is. None is created or deleted from here.
*/
class SettingController extends Controller
{
/**
* Written by the application, never by hand: a hand-set content version
* would leave the frontend's cache serving stale pages or refetching them
* for nothing.
*/
protected const READ_ONLY = [ContentVersion::KEY];
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('settings.view');
return SettingResource::collection(Setting::query()->orderBy('key')->get());
}
public function show(Setting $setting): SettingResource
{
$this->authorize('settings.view');
return SettingResource::make($setting);
}
public function update(Request $request, Setting $setting): SettingResource
{
// Taking the site down is its own permission, in the panel as here.
$this->authorize($setting->key === Maintenance::KEY ? 'settings.maintenance' : 'settings.update');
abort_if(in_array($setting->key, self::READ_ONLY, true), 403, __('rules.settings.read_only'));
// `present` rather than `required`: null is a value a setting may hold.
$request->validate(['value' => ['present']]);
$setting->update(['value' => $request->input('value')]);
return SettingResource::make($setting);
}
}

View 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),
];
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\TalkRequest;
use App\Http\Resources\TalkResource;
use App\Models\Talk;
use App\Support\TranslatablePayload;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* Talks given elsewhere. Deleted for good: there is no bin for them.
*/
class TalkController extends Controller
{
/**
* Filters: is_public, member_id, project_id, search (title or event).
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Talk::class);
$talks = Talk::query()
->when($request->has('is_public'), fn ($q) => $q->where('is_public', $request->boolean('is_public')))
->when($request->filled('member_id'), fn ($q) => $q->where('member_id', $request->integer('member_id')))
->when($request->filled('project_id'), fn ($q) => $q->where('project_id', $request->integer('project_id')))
->when($request->filled('search'), function ($q) use ($request): void {
$term = '%'.$request->string('search').'%';
$q->where(fn ($q) => $q->where('title', 'like', $term)->orWhere('event', 'like', $term));
})
->recentFirst()
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return TalkResource::collection($talks);
}
public function store(TalkRequest $request): JsonResponse
{
$this->authorize('create', Talk::class);
return TalkResource::make($this->save(new Talk, $request))->response()->setStatusCode(201);
}
public function show(Talk $talk): TalkResource
{
$this->authorize('view', $talk);
return TalkResource::make($talk);
}
public function update(TalkRequest $request, Talk $talk): TalkResource
{
$this->authorize('update', $talk);
return TalkResource::make($this->save($talk, $request));
}
public function destroy(Talk $talk): JsonResponse
{
$this->authorize('delete', $talk);
$talk->delete();
return response()->json(status: 204);
}
protected function save(Talk $talk, TalkRequest $request): Talk
{
$talk->fill(TranslatablePayload::merge($talk, $request->validated(), $talk->translatable));
$talk->save();
return $talk;
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\TechnologyCategory;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\TechnologyRequest;
use App\Http\Resources\TechnologyResource;
use App\Models\Technology;
use App\Support\TranslatablePayload;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* The technologies the collective uses, and their place on the radar.
*
* Same rules as the panel: TechnologyPolicy refuses to delete one a project,
* member or call still points at.
*/
class TechnologyController extends Controller
{
/**
* Filters: category, radar_ring (`none` for those off the radar), search.
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Technology::class);
$technologies = Technology::query()
->with('logoMedia')
->withCount('projects')
->when($request->filled('category'), fn ($q) => $q->where('category', $request->string('category')))
->when($request->filled('radar_ring'), fn ($q) => $request->string('radar_ring')->toString() === 'none'
? $q->whereNull('radar_ring')
: $q->where('radar_ring', $request->string('radar_ring')))
->when($request->filled('search'), fn ($q) => $q->where('name', 'like', '%'.$request->string('search').'%'))
->orderBy('position')
->orderBy('name')
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return TechnologyResource::collection($technologies);
}
public function store(TechnologyRequest $request): JsonResponse
{
$this->authorize('create', Technology::class);
$technology = new Technology;
$technology->fill(TranslatablePayload::merge($technology, $request->validated(), $technology->translatable));
// The column's default, and the panel's, stated here so the model
// knows it too: otherwise the answer to a create without a category
// would be missing the one the row was given.
$technology->category ??= TechnologyCategory::Tool;
$technology->save();
return TechnologyResource::make($this->loaded($technology))->response()->setStatusCode(201);
}
public function show(Technology $technology): TechnologyResource
{
$this->authorize('view', $technology);
return TechnologyResource::make($this->loaded($technology));
}
public function update(TechnologyRequest $request, Technology $technology): TechnologyResource
{
$this->authorize('update', $technology);
$technology->fill(TranslatablePayload::merge($technology, $request->validated(), $technology->translatable));
$technology->save();
return TechnologyResource::make($this->loaded($technology));
}
public function destroy(Technology $technology): JsonResponse
{
$this->authorize('delete', $technology);
$technology->delete();
return response()->json(status: 204);
}
protected function loaded(Technology $technology): Technology
{
return $technology->load('logoMedia')->loadCount('projects');
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\TestimonialRequest;
use App\Http\Resources\TestimonialResource;
use App\Models\Testimonial;
use App\Support\TranslatablePayload;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* What people the collective worked with said about it.
*/
class TestimonialController extends Controller
{
protected const WITH = ['avatarMedia'];
/**
* Filters: is_public, client_id, project_id, service_id, search (author), trashed.
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Testimonial::class);
$testimonials = Testimonial::query()
->with(self::WITH)
->when($request->has('is_public'), fn ($q) => $q->where('is_public', $request->boolean('is_public')))
->when($request->filled('client_id'), fn ($q) => $q->where('client_id', $request->integer('client_id')))
->when($request->filled('project_id'), fn ($q) => $q->where('project_id', $request->integer('project_id')))
->when($request->filled('service_id'), fn ($q) => $q->where('service_id', $request->integer('service_id')))
->when($request->filled('search'), fn ($q) => $q->where('author_name', 'like', '%'.$request->string('search').'%'))
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
->orderBy('position')
->latest('id')
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return TestimonialResource::collection($testimonials);
}
public function store(TestimonialRequest $request): JsonResponse
{
$this->authorize('create', Testimonial::class);
return TestimonialResource::make($this->save(new Testimonial, $request))->response()->setStatusCode(201);
}
public function show(Testimonial $testimonial): TestimonialResource
{
$this->authorize('view', $testimonial);
return TestimonialResource::make($testimonial->load(self::WITH));
}
public function update(TestimonialRequest $request, Testimonial $testimonial): TestimonialResource
{
$this->authorize('update', $testimonial);
return TestimonialResource::make($this->save($testimonial, $request));
}
public function destroy(Testimonial $testimonial): JsonResponse
{
$this->authorize('delete', $testimonial);
$testimonial->delete();
return response()->json(status: 204);
}
public function restore(Testimonial $testimonial): TestimonialResource
{
$this->authorize('restore', $testimonial);
$testimonial->restore();
return TestimonialResource::make($testimonial->load(self::WITH));
}
protected function save(Testimonial $testimonial, TestimonialRequest $request): Testimonial
{
$testimonial->fill(TranslatablePayload::merge($testimonial, $request->validated(), $testimonial->translatable));
$testimonial->save();
return $testimonial->load(self::WITH);
}
}

View 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);
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Enums\ApplicationStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* What the inbox lets anyone change about a submission: nothing the sender
* wrote, only how it is being handled.
*/
class ApplicationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'status' => ['sometimes', Rule::enum(ApplicationStatus::class)],
'notes' => ['sometimes', 'nullable', 'string', 'max:10000'],
];
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Enums\CallStatus;
use App\Models\Call;
use App\Rules\Translatable;
use App\Rules\UniqueTranslatedSlug;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* The panel's call form, as rules. `technology_ids` is replaced whole when
* sent.
*/
class CallRequest extends FormRequest
{
public const SENIORITIES = ['any', 'junior', 'mid', 'senior'];
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$call = $this->route('call');
$creating = ! $call instanceof Call;
$sometimes = $creating ? 'required' : 'sometimes';
return [
'title' => [$sometimes, new Translatable(requireDefaultLocale: $creating, max: 255)],
'slug' => [
$sometimes,
new Translatable(requireDefaultLocale: $creating, max: 255),
new UniqueTranslatedSlug(Call::class, $call?->id),
],
'description' => ['sometimes', 'nullable', new Translatable],
'what_you_get' => ['sometimes', 'nullable', new Translatable],
'status' => ['sometimes', Rule::enum(CallStatus::class)],
'hours_per_week' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:40'],
'duration_weeks' => ['sometimes', 'nullable', 'integer', 'min:1'],
'seniority' => ['sometimes', 'nullable', Rule::in(self::SENIORITIES)],
'opens_at' => ['sometimes', 'nullable', 'date'],
'closes_at' => ['sometimes', 'nullable', 'date', 'after_or_equal:opens_at'],
'project_id' => ['sometimes', 'nullable', 'integer', Rule::exists('projects', 'id')->whereNull('deleted_at')],
'technology_ids' => ['sometimes', 'array'],
'technology_ids.*' => ['integer', 'distinct', Rule::exists('technologies', 'id')],
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Models\Client;
use App\Rules\Translatable;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* The panel's client form, as rules.
*/
class ClientRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$client = $this->route('client');
$creating = ! $client instanceof Client;
$sometimes = $creating ? 'required' : 'sometimes';
return [
'name' => [$sometimes, 'string', 'max:255'],
'slug' => [
$sometimes,
'string',
'max:255',
'alpha_dash:ascii',
Rule::unique('clients', 'slug')->ignore($client?->id),
],
'sector' => ['sometimes', 'nullable', new Translatable(max: 120)],
'logo' => ['sometimes', 'nullable', 'string', 'max:2048'],
'url' => ['sometimes', 'nullable', 'url', 'max:2048'],
'is_public' => ['sometimes', 'boolean'],
'position' => ['sometimes', 'integer', 'min:0'],
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Concerns;
use Illuminate\Validation\Validator;
/**
* Publishing is a permission of its own, as in the panel — where these fields
* are disabled for whoever lacks it. Here they are refused, so someone who may
* write a record still cannot put it on the site, or take it off, alone.
*/
trait GuardsPublication
{
/**
* @param array<int, string> $fields
*/
protected function guardPublication(Validator $validator, string $permission, array $fields): void
{
$validator->after(function (Validator $validator) use ($permission, $fields): void {
if ($this->user()?->can($permission)) {
return;
}
foreach ($fields as $field) {
if ($this->has($field)) {
$validator->errors()->add($field, __('rules.publication.forbidden'));
}
}
});
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
/**
* An image arrives one of two ways: as the request's `file` (multipart), or as
* a `url` the API downloads itself (JSON). Exactly one.
*
* What the declared type says is only a first filter. The controller looks at
* the bytes, because a client names the type of what it sends and a URL's
* server names the type of what it answers — and neither has to be telling
* the truth.
*/
class MediaRequest extends FormRequest
{
/**
* Authorisation is the controller's, as everywhere else in this API — and
* it has to happen before a URL is fetched, which is after validation.
*/
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'file' => [
'required_without:url',
'prohibits:url',
'file',
'max:'.(int) config('media.upload.max_kb'),
'mimetypes:'.implode(',', (array) config('media.upload.mimes')),
],
'url' => ['required_without:file', 'string', 'url:https', 'max:2048'],
'for' => ['sometimes', 'string', 'in:'.implode(',', array_keys((array) config('media.upload.for')))],
];
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Models\Member;
use App\Rules\Translatable;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* The panel's member form, as rules. A name is a person's, the same in every
* language, and so is the slug built from it; only the headline and the bio
* are translatable.
*
* `links` is a label → URL map (`{"github": "https://…"}`). `technology_ids`
* is replaced whole when sent. Which projects someone is on is set from the
* project, where the role belongs.
*/
class MemberRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$member = $this->route('member');
$creating = ! $member instanceof Member;
$sometimes = $creating ? 'required' : 'sometimes';
return [
'name' => [$sometimes, 'string', 'max:255'],
'slug' => [
$sometimes,
'string',
'max:255',
'alpha_dash:ascii',
Rule::unique('members', 'slug')->ignore($member?->id),
],
'headline' => ['sometimes', 'nullable', new Translatable(max: 255)],
'bio' => ['sometimes', 'nullable', new Translatable],
'avatar' => ['sometimes', 'nullable', 'string', 'max:2048'],
// Never shown on the site; the panel keeps it for the collective.
'email' => ['sometimes', 'nullable', 'email', 'max:255'],
'github_username' => ['sometimes', 'nullable', 'string', 'max:120'],
'links' => ['sometimes', 'nullable', 'array', 'max:10'],
'links.*' => ['nullable', 'url', 'max:2048'],
'is_public' => ['sometimes', 'boolean'],
'is_founder' => ['sometimes', 'boolean'],
'joined_at' => ['sometimes', 'nullable', 'date'],
'left_at' => ['sometimes', 'nullable', 'date', 'after_or_equal:joined_at'],
'position' => ['sometimes', 'integer', 'min:0'],
'technology_ids' => ['sometimes', 'array'],
'technology_ids.*' => ['integer', 'distinct', Rule::exists('technologies', 'id')],
];
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Models\MissingUrl;
use App\Models\Redirect;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* Where a missing address should go. The origin is the address itself.
*/
class MissingUrlRedirectRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
if (is_string($this->input('to')) && filled($this->input('to'))) {
$this->merge(['to' => Redirect::normalise($this->input('to'))]);
}
// The language it was seen in, unless told otherwise; null for all.
if (! $this->exists('locale')) {
$this->merge(['locale' => $this->missingUrl()->locale]);
}
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$path = $this->missingUrl()->path;
$locale = $this->input('locale');
return [
'to' => ['required', 'string', 'max:500', Rule::notIn([$path])],
'status' => ['sometimes', 'integer', Rule::in([301, 302])],
'locale' => [
'nullable',
'string',
Rule::in(array_keys((array) config('locales.supported'))),
// Already answered by a rule: edit that one instead.
function (string $attribute, mixed $value, \Closure $fail) use ($path, $locale): void {
$exists = Redirect::query()
->where('from', $path)
->when($locale === null, fn ($q) => $q->whereNull('locale'), fn ($q) => $q->where('locale', $locale))
->exists();
if ($exists) {
$fail(__('validation.unique', ['attribute' => 'from']));
}
},
],
];
}
protected function missingUrl(): MissingUrl
{
return $this->route('missing_url');
}
}

View File

@@ -49,6 +49,10 @@ class PageRequest extends FormRequest
'meta_description' => ['sometimes', 'nullable', new Translatable(max: 160)],
'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)],
'featured_image' => ['sometimes', 'nullable', 'string', 'max:2048'],
'featured_image_alt' => ['sometimes', 'nullable', new Translatable(max: 255)],

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Enums\ProjectStatus;
use App\Http\Requests\Api\V1\Concerns\GuardsPublication;
use App\Models\Project;
use App\Rules\Translatable;
use App\Rules\UniqueTranslatedSlug;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
/**
* The panel's project form, as rules. On update every field is optional
* (PATCH semantics) but keeps the same shape.
*
* Relations travel as ids and are replaced whole when sent: `technology_ids`,
* `service_ids`, and `members` with each one's role on the project.
*/
class ProjectRequest extends FormRequest
{
use GuardsPublication;
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$project = $this->route('project');
$creating = ! $project instanceof Project;
$sometimes = $creating ? 'required' : 'sometimes';
return [
'title' => [$sometimes, new Translatable(requireDefaultLocale: $creating, max: 255)],
'slug' => [
$sometimes,
new Translatable(requireDefaultLocale: $creating, max: 255),
new UniqueTranslatedSlug(Project::class, $project?->id),
],
'tagline' => ['sometimes', 'nullable', new Translatable(max: 300)],
'description' => ['sometimes', 'nullable', new Translatable],
'decisions' => ['sometimes', 'nullable', new Translatable],
'postmortem' => ['sometimes', 'nullable', new Translatable],
'meta_title' => ['sometimes', 'nullable', new Translatable(max: 60)],
'meta_description' => ['sometimes', 'nullable', new Translatable(max: 160)],
'status' => ['sometimes', Rule::enum(ProjectStatus::class)],
'is_featured' => ['sometimes', 'boolean'],
'needs_help' => ['sometimes', 'boolean'],
'demo_url' => ['sometimes', 'nullable', 'url', 'max:2048'],
'cover_image' => ['sometimes', 'nullable', 'string', 'max:2048'],
'cover_image_alt' => ['sometimes', 'nullable', new Translatable(max: 255)],
'started_at' => ['sometimes', 'nullable', 'date'],
'ended_at' => ['sometimes', 'nullable', 'date', 'after_or_equal:started_at'],
'published_at' => ['sometimes', 'nullable', 'date'],
'unpublish_at' => ['sometimes', 'nullable', 'date', 'after:published_at'],
'position' => ['sometimes', 'integer', 'min:0'],
'client_id' => ['sometimes', 'nullable', 'integer', Rule::exists('clients', 'id')->whereNull('deleted_at')],
'lead_member_id' => ['sometimes', 'nullable', 'integer', Rule::exists('members', 'id')->whereNull('deleted_at')],
'technology_ids' => ['sometimes', 'array'],
'technology_ids.*' => ['integer', 'distinct', Rule::exists('technologies', 'id')],
'service_ids' => ['sometimes', 'array'],
'service_ids.*' => ['integer', 'distinct', Rule::exists('services', 'id')->whereNull('deleted_at')],
'members' => ['sometimes', 'array'],
'members.*.id' => ['required', 'integer', 'distinct', Rule::exists('members', 'id')->whereNull('deleted_at')],
'members.*.role' => ['nullable', 'string', 'max:120'],
];
}
public function withValidator(Validator $validator): void
{
$this->guardPublication($validator, 'projects.publish', ['published_at', 'unpublish_at']);
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Models\Redirect;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* The panel's redirect form, as rules. Both ends are normalised before they
* are checked, so `/a/` and `/a` are the same rule however it was typed.
*/
class RedirectRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
foreach (['from', 'to'] as $end) {
if (is_string($this->input($end)) && filled($this->input($end))) {
$this->merge([$end => Redirect::normalise($this->input($end))]);
}
}
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$redirect = $this->route('redirect');
$creating = ! $redirect instanceof Redirect;
$sometimes = $creating ? 'required' : 'sometimes';
$from = $this->input('from', $redirect?->from);
$locale = $this->exists('locale') ? $this->input('locale') : $redirect?->locale;
return [
'from' => [
$sometimes,
'string',
'max:500',
// One rule per address and language, as the table's index says.
Rule::unique('redirects', 'from')
->ignore($redirect?->id)
->where(fn ($q) => $locale === null ? $q->whereNull('locale') : $q->where('locale', $locale)),
],
'to' => [$sometimes, 'string', 'max:500', Rule::notIn([$from])],
'status' => ['sometimes', 'integer', Rule::in([301, 302])],
'locale' => ['sometimes', 'nullable', 'string', Rule::in(array_keys((array) config('locales.supported')))],
'is_enabled' => ['sometimes', 'boolean'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return ['to.not_in' => __('validation.different', ['attribute' => 'to', 'other' => 'from'])];
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Enums\GitProvider;
use App\Models\Repository;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* The panel's repository form, as rules. The synced columns are not here on
* purpose: nothing a caller sends may fake a repository's numbers.
*/
class RepositoryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$repository = $this->route('repository');
$creating = ! $repository instanceof Repository;
$sometimes = $creating ? 'required' : 'sometimes';
$provider = $this->input('provider', $repository?->provider?->value);
return [
'provider' => [$sometimes, Rule::enum(GitProvider::class)],
'full_name' => [
$sometimes,
'string',
'max:255',
'regex:/^[\w.-]+\/[\w.-]+$|^[\w.-]+$/',
Rule::unique('repositories', 'full_name')
->ignore($repository?->id)
->where('provider', $provider),
],
'project_id' => ['sometimes', 'nullable', 'integer', Rule::exists('projects', 'id')->whereNull('deleted_at')],
'is_primary' => ['sometimes', 'boolean'],
'show_publicly' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Http\Requests\Api\V1\Concerns\GuardsPublication;
use App\Models\Service;
use App\Rules\Translatable;
use App\Rules\UniqueTranslatedSlug;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
/**
* The panel's service form, as rules. `technology_ids` and `project_ids` are
* replaced whole when sent.
*/
class ServiceRequest extends FormRequest
{
use GuardsPublication;
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$service = $this->route('service');
$creating = ! $service instanceof Service;
$sometimes = $creating ? 'required' : 'sometimes';
return [
'title' => [$sometimes, new Translatable(requireDefaultLocale: $creating, max: 255)],
'slug' => [
$sometimes,
new Translatable(requireDefaultLocale: $creating, max: 255),
new UniqueTranslatedSlug(Service::class, $service?->id),
],
'tagline' => ['sometimes', 'nullable', new Translatable(max: 255)],
'description' => ['sometimes', 'nullable', new Translatable],
'meta_title' => ['sometimes', 'nullable', new Translatable(max: 60)],
'meta_description' => ['sometimes', 'nullable', new Translatable(max: 160)],
// A name from the icon set the frontend resolves, e.g. `support`.
'icon' => ['sometimes', 'nullable', 'string', 'max:120'],
'is_featured' => ['sometimes', 'boolean'],
'position' => ['sometimes', 'integer', 'min:0'],
'is_published' => ['sometimes', 'boolean'],
'published_at' => ['sometimes', 'nullable', 'date'],
'unpublish_at' => ['sometimes', 'nullable', 'date', 'after:published_at'],
'technology_ids' => ['sometimes', 'array'],
'technology_ids.*' => ['integer', 'distinct', Rule::exists('technologies', 'id')],
'project_ids' => ['sometimes', 'array'],
'project_ids.*' => ['integer', 'distinct', Rule::exists('projects', 'id')->whereNull('deleted_at')],
];
}
public function withValidator(Validator $validator): void
{
$this->guardPublication($validator, 'services.publish', ['is_published', 'published_at', 'unpublish_at']);
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Models\Talk;
use App\Rules\Translatable;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* The panel's talk form, as rules. The title is the one the talk was given
* under, in `language`; only the summary is translated.
*/
class TalkRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$creating = ! $this->route('talk') instanceof Talk;
$sometimes = $creating ? 'required' : 'sometimes';
return [
'title' => [$sometimes, 'string', 'max:255'],
'language' => [$sometimes, 'string', Rule::in(array_keys((array) config('locales.supported')))],
'summary' => ['sometimes', 'nullable', new Translatable(max: 500)],
'event' => [$sometimes, 'string', 'max:255'],
'city' => ['sometimes', 'nullable', 'string', 'max:120'],
'held_at' => ['sometimes', 'nullable', 'date'],
'video_url' => ['sometimes', 'nullable', 'url', 'max:2048'],
'slides_url' => ['sometimes', 'nullable', 'url', 'max:2048'],
'member_id' => ['sometimes', 'nullable', 'integer', Rule::exists('members', 'id')->whereNull('deleted_at')],
'project_id' => ['sometimes', 'nullable', 'integer', Rule::exists('projects', 'id')->whereNull('deleted_at')],
'is_public' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Enums\RadarRing;
use App\Enums\TechnologyCategory;
use App\Models\Technology;
use App\Rules\Translatable;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* The panel's technology form, as rules. On update every field is optional
* (PATCH semantics) but keeps the same shape.
*
* `name` and `slug` are plain strings: a technology's name is a proper noun,
* the same in every language. Only the prose about it is translatable.
*/
class TechnologyRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$technology = $this->route('technology');
$creating = ! $technology instanceof Technology;
$sometimes = $creating ? 'required' : 'sometimes';
return [
'name' => [$sometimes, 'string', 'max:120'],
'slug' => [
$sometimes,
'string',
'max:120',
'alpha_dash:ascii',
Rule::unique('technologies', 'slug')->ignore($technology?->id),
],
'description' => ['sometimes', 'nullable', new Translatable(max: 1000)],
'radar_note' => ['sometimes', 'nullable', new Translatable(max: 1000)],
'category' => ['sometimes', Rule::enum(TechnologyCategory::class)],
// Null keeps it off the radar while projects can still be tagged with it.
'radar_ring' => ['sometimes', 'nullable', Rule::enum(RadarRing::class)],
'color' => ['sometimes', 'nullable', 'string', 'regex:/^#[0-9a-fA-F]{6}$/'],
'logo' => ['sometimes', 'nullable', 'string', 'max:255'],
// 255, not the panel's 2048: that is the column's width.
'website' => ['sometimes', 'nullable', 'url', 'max:255'],
'position' => ['sometimes', 'integer', 'min:0'],
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Models\Testimonial;
use App\Rules\Translatable;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* The panel's testimonial form, as rules.
*
* `original_locale` says which language the person actually spoke in; the
* other entries of `quote` are translations of it.
*/
class TestimonialRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$creating = ! $this->route('testimonial') instanceof Testimonial;
$sometimes = $creating ? 'required' : 'sometimes';
return [
'quote' => [$sometimes, new Translatable(max: 1000)],
'original_locale' => [$sometimes, 'string', Rule::in(array_keys((array) config('locales.supported')))],
'author_name' => [$sometimes, 'string', 'max:255'],
'author_role' => ['sometimes', 'nullable', new Translatable(max: 255)],
'avatar' => ['sometimes', 'nullable', 'string', 'max:2048'],
'client_id' => ['sometimes', 'nullable', 'integer', Rule::exists('clients', 'id')->whereNull('deleted_at')],
'project_id' => ['sometimes', 'nullable', 'integer', Rule::exists('projects', 'id')->whereNull('deleted_at')],
'service_id' => ['sometimes', 'nullable', 'integer', Rule::exists('services', 'id')->whereNull('deleted_at')],
'is_public' => ['sometimes', 'boolean'],
'position' => ['sometimes', 'integer', 'min:0'],
'given_at' => ['sometimes', 'nullable', 'date'],
];
}
}

View 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'],
];
}
}

View 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(),
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Application;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Application
*/
class ApplicationResource extends JsonResource
{
/**
* `ip_hash` stays out: it exists to spot the same sender twice, not to be
* handed around.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'kind' => $this->kind->value,
'status' => $this->status->value,
'call_id' => $this->call_id,
'subject' => $this->resource->subject(),
'name' => $this->name,
'email' => $this->email,
'organisation' => $this->organisation,
'message' => $this->message,
'hours_per_week' => $this->hours_per_week,
'links' => (object) ($this->links ?? []),
'locale' => $this->locale,
'notes' => $this->notes,
'handled_by' => $this->whenLoaded('handler', fn () => $this->handler ? [
'id' => $this->handler->id,
'name' => $this->handler->name,
] : null),
'handled_at' => $this->handled_at?->toIso8601String(),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
'deleted_at' => $this->deleted_at?->toIso8601String(),
];
}
}

View 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(),
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Call;
use App\Support\TranslatablePayload;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Call
*/
class CallResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
return [
'id' => $this->id,
'locale' => $locale,
...TranslatablePayload::resolve($this->resource, $this->translatable, $locale),
'status' => $this->status->value,
'hours_per_week' => $this->hours_per_week,
'duration_weeks' => $this->duration_weeks,
'seniority' => $this->seniority,
'opens_at' => $this->opens_at?->toDateString(),
'closes_at' => $this->closes_at?->toDateString(),
'project_id' => $this->project_id,
'technology_ids' => $this->whenLoaded('technologies', fn () => $this->technologies->pluck('id')->values()),
'applications_count' => $this->whenCounted('applications'),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
'translations' => $this->when(
in_array('translations', explode(',', (string) $request->query('with')), true),
fn () => TranslatablePayload::all($this->resource, $this->translatable),
),
];
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Client;
use App\Support\ImagePayload;
use App\Support\TranslatablePayload;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Client
*/
class ClientResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
return [
'id' => $this->id,
'locale' => $locale,
'name' => $this->name,
'slug' => $this->slug,
...TranslatablePayload::resolve($this->resource, $this->translatable, $locale),
'logo' => $this->logo,
'logo_media' => ImagePayload::make(
$this->logo,
$this->resource->relationLoaded('logoMedia') ? $this->resource->getRelation('logoMedia') : null,
$this->name,
),
'url' => $this->url,
'is_public' => (bool) $this->is_public,
'position' => $this->position,
'projects_count' => $this->whenCounted('projects'),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
'deleted_at' => $this->deleted_at?->toIso8601String(),
'translations' => $this->when(
in_array('translations', explode(',', (string) $request->query('with')), true),
fn () => TranslatablePayload::all($this->resource, $this->translatable),
),
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Media;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Media
*/
class MediaResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
// What a record stores — `featured_image` on a page — and so the one
// field a caller has to keep.
'path' => $this->path,
'url' => $this->url(),
'mime' => $this->mime,
'size' => $this->size,
'width' => $this->width,
'height' => $this->height,
// False until the queue has derived the smaller versions and the
// placeholder. The image serves either way.
'processed' => $this->isProcessed(),
'error' => $this->error,
// Only in the library, where it decides whether a file may go.
'referenced' => $this->when(
array_key_exists('referenced', $this->resource->getAttributes()),
fn () => (bool) $this->resource->getAttribute('referenced'),
),
'created_at' => $this->created_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Member;
use App\Support\ImagePayload;
use App\Support\TranslatablePayload;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Member
*/
class MemberResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
return [
'id' => $this->id,
'locale' => $locale,
'name' => $this->name,
'slug' => $this->slug,
...TranslatablePayload::resolve($this->resource, $this->translatable, $locale),
'avatar' => $this->avatar,
'avatar_media' => ImagePayload::make(
$this->avatar,
$this->resource->relationLoaded('avatarMedia') ? $this->resource->getRelation('avatarMedia') : null,
$this->name,
),
'email' => $this->email,
'github_username' => $this->github_username,
'links' => (object) ($this->links ?? []),
'is_public' => (bool) $this->is_public,
'is_founder' => (bool) $this->is_founder,
'joined_at' => $this->joined_at?->toDateString(),
'left_at' => $this->left_at?->toDateString(),
'position' => $this->position,
'technology_ids' => $this->whenLoaded('technologies', fn () => $this->technologies->pluck('id')->values()),
'projects' => $this->whenLoaded('projects', fn () => $this->projects->map(fn ($project) => [
'id' => $project->id,
'title' => $project->getTranslation('title', $locale),
'role' => $project->pivot->role,
])->values()),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
'deleted_at' => $this->deleted_at?->toIso8601String(),
'translations' => $this->when(
in_array('translations', explode(',', (string) $request->query('with')), true),
fn () => TranslatablePayload::all($this->resource, $this->translatable),
),
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\MissingUrl;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin MissingUrl
*/
class MissingUrlResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'path' => $this->path,
'locale' => $this->locale,
'referrer' => $this->referrer,
// Our own page links here: more urgent than someone else's stale link.
'internal_referrer' => $this->resource->isInternalReferrer(),
'status' => $this->status->value,
'hits' => (int) $this->hits,
'first_seen_at' => $this->first_seen_at?->toIso8601String(),
'last_seen_at' => $this->last_seen_at?->toIso8601String(),
'resolved_at' => $this->resolved_at?->toIso8601String(),
];
}
}

View File

@@ -49,6 +49,8 @@ class PageResource extends JsonResource
'category' => CategoryResource::make($this->whenLoaded('category')),
'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,
// which is what a translation UI needs and a page renderer does not.

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Project;
use App\Support\ImagePayload;
use App\Support\TranslatablePayload;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Project
*/
class ProjectResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
$resolved = TranslatablePayload::resolve($this->resource, $this->translatable, $locale);
return [
'id' => $this->id,
'locale' => $locale,
...$resolved,
'status' => $this->status->value,
'is_featured' => (bool) $this->is_featured,
'needs_help' => (bool) $this->needs_help,
'demo_url' => $this->demo_url,
// The stored path, which is what a client PATCHes back; what was
// derived from it travels alongside, as on a page.
'cover_image' => $this->cover_image,
'cover_image_media' => ImagePayload::make(
$this->cover_image,
$this->resource->relationLoaded('coverMedia') ? $this->resource->getRelation('coverMedia') : null,
$resolved['cover_image_alt'] ?? null,
),
'started_at' => $this->started_at?->toDateString(),
'ended_at' => $this->ended_at?->toDateString(),
'published_at' => $this->published_at?->toIso8601String(),
'unpublish_at' => $this->unpublish_at?->toIso8601String(),
'position' => $this->position,
'client_id' => $this->client_id,
'lead_member_id' => $this->lead_member_id,
'technology_ids' => $this->whenLoaded('technologies', fn () => $this->technologies->pluck('id')->values()),
'service_ids' => $this->whenLoaded('services', fn () => $this->services->pluck('id')->values()),
'members' => $this->whenLoaded('members', fn () => $this->members->map(fn ($member) => [
'id' => $member->id,
'name' => $member->name,
'role' => $member->pivot->role,
])->values()),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
'deleted_at' => $this->deleted_at?->toIso8601String(),
'translations' => $this->when(
in_array('translations', explode(',', (string) $request->query('with')), true),
fn () => TranslatablePayload::all($this->resource, $this->translatable),
),
];
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Resources\PublicApi;
use App\Models\Page;
use App\Models\Tag;
use App\Support\ImagePayload;
use App\Support\SiteUrls;
use App\Support\TranslatablePayload;
@@ -58,6 +59,29 @@ class PublicPageResource extends JsonResource
'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
// actually have a translation appear.
'alternates' => SiteUrls::alternates($this->resource, 'page'),

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Redirect;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Redirect
*/
class RedirectResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'from' => $this->from,
'to' => $this->to,
'status' => $this->status,
'locale' => $this->locale,
'is_enabled' => (bool) $this->is_enabled,
'automatic' => $this->resource->wasAutomatic(),
'hits' => (int) $this->hits,
'last_used_at' => $this->last_used_at?->toIso8601String(),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Repository;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Repository
*/
class RepositoryResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'provider' => $this->provider->value,
'full_name' => $this->full_name,
'project_id' => $this->project_id,
'is_primary' => (bool) $this->is_primary,
'show_publicly' => (bool) $this->show_publicly,
// What the provider last reported.
'html_url' => $this->html_url,
'description' => $this->description,
'homepage' => $this->homepage,
'default_branch' => $this->default_branch,
'primary_language' => $this->primary_language,
'license' => $this->license,
'topics' => $this->topics ?? [],
'stars' => (int) $this->stars,
'forks' => (int) $this->forks,
'open_issues_count' => (int) $this->open_issues_count,
'is_archived' => (bool) $this->is_archived,
'pushed_at' => $this->pushed_at?->toIso8601String(),
'last_synced_at' => $this->last_synced_at?->toIso8601String(),
'sync_error' => $this->sync_error,
'sync_failures' => (int) $this->sync_failures,
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
];
}
}

View 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(),
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Revision;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Revision
*/
class RevisionResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'event' => $this->event,
'author' => [
'id' => $this->user_id,
'name' => $this->resource->author(),
],
// Field => {old, new}. Translatable fields carry every language.
'changes' => (object) ($this->changeset ?? []),
'created_at' => $this->created_at?->toIso8601String(),
];
}
}

Some files were not shown because too many files have changed in this diff Show More