Compare commits

..

4 Commits

Author SHA1 Message Date
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
41 changed files with 4067 additions and 11 deletions

View File

@@ -403,8 +403,8 @@ tabla es derivada, así que reconstruirla siempre es seguro.
`POST /api/v1/media` es el campo de subida de una ficha, sin la ficha: guarda la `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 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` se manda como `featured_image` de una página o `logo` de una tecnología. `for`
dice para qué es (`pages`, por defecto, o `technologies`), y con ello la carpeta dice para qué es (`pages`, por defecto, `technologies`, `projects`, `members`,
y el permiso que hace falta. Existe para el [MCP](#servidor-mcp), que sin él `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()`, 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. así que se procesa, se sirve y se poda igual que una subida desde el panel.
@@ -1269,7 +1269,31 @@ GET /api/v1/roles/catalogue catálogo de permisos agrupado
/api/v1/services CRUD + restore; technology_ids, project_ids /api/v1/services CRUD + restore; technology_ids, project_ids
/api/v1/technologies CRUD completo; filtros: category, /api/v1/technologies CRUD completo; filtros: category,
radar_ring (none = fuera del radar), search 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) 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 Publicar sigue siendo un permiso aparte, como en el panel (donde esos campos
@@ -1278,6 +1302,14 @@ salen desactivados): sin `projects.publish` o `services.publish`, la API rechaza
algo no puede sacarlo a la web solo. Las relaciones enviadas sustituyen a las algo no puede sacarlo a la web solo. Las relaciones enviadas sustituyen a las
que había, y las no enviadas no se tocan; todo se guarda en una transacción. que había, y las no enviadas no se tocan; todo se guarda en una transacción.
Los ajustes siguen los permisos del panel: `settings.view` para leer,
`settings.update` para cambiar y `settings.maintenance` para `site.maintenance`
y `/v1/maintenance`. `content.version` lo escribe la aplicación y la API lo
rechaza: puesto a mano, el frontend serviría páginas viejas o las pediría de
nuevo para nada. Sincronizar un repositorio pide `repositories.sync`, porque
gasta un límite del proveedor que comparte toda la aplicación, y va a la cola
en vez de hacer esperar a quien llama.
`GET /v1/me` devuelve el array `permissions`: úsalo para construir la UI del `GET /v1/me` devuelve el array `permissions`: úsalo para construir la UI del
cliente en vez de comprobar nombres de rol. cliente en vez de comprobar nombres de rol.

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

@@ -7,19 +7,22 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\MediaRequest; use App\Http\Requests\Api\V1\MediaRequest;
use App\Http\Resources\MediaResource; use App\Http\Resources\MediaResource;
use App\Jobs\ProcessMedia;
use App\Models\Media; use App\Models\Media;
use App\Services\Media\RemoteImage; use App\Services\Media\RemoteImage;
use enshrined\svgSanitize\Sanitizer; use enshrined\svgSanitize\Sanitizer;
use finfo; use finfo;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
/** /**
* POST /api/v1/media * The media library: every image on the media disk, and uploading new ones.
* *
* Stores an image and answers with the path a record points at — what the * `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 * 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. * server, which otherwise could create a page but never give it a picture.
* *
@@ -44,6 +47,75 @@ class MediaController extends Controller
'image/svg+xml' => 'svg', '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 public function store(MediaRequest $request, RemoteImage $remote): JsonResponse
{ {
$for = (string) $request->input('for', 'pages'); $for = (string) $request->input('for', 'pages');

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

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

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

@@ -19,6 +19,7 @@ class MediaResource extends JsonResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
'id' => $this->id,
// What a record stores — `featured_image` on a page — and so the one // What a record stores — `featured_image` on a page — and so the one
// field a caller has to keep. // field a caller has to keep.
'path' => $this->path, 'path' => $this->path,
@@ -30,6 +31,13 @@ class MediaResource extends JsonResource
// False until the queue has derived the smaller versions and the // False until the queue has derived the smaller versions and the
// placeholder. The image serves either way. // placeholder. The image serves either way.
'processed' => $this->isProcessed(), '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,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

@@ -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,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Setting;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Setting
*/
class SettingResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'key' => $this->key,
'value' => $this->value,
'updated_at' => $this->updated_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Talk;
use App\Support\TranslatablePayload;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Talk
*/
class TalkResource 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,
'title' => $this->title,
'language' => $this->language,
...TranslatablePayload::resolve($this->resource, $this->translatable, $locale),
'event' => $this->event,
'city' => $this->city,
'held_at' => $this->held_at?->toDateString(),
'video_url' => $this->video_url,
'slides_url' => $this->slides_url,
'member_id' => $this->member_id,
'project_id' => $this->project_id,
'is_public' => (bool) $this->is_public,
'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,56 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Testimonial;
use App\Support\ImagePayload;
use App\Support\TranslatablePayload;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Testimonial
*/
class TestimonialResource 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),
'original_locale' => $this->original_locale,
'author_name' => $this->author_name,
'avatar' => $this->avatar,
'avatar_media' => ImagePayload::make(
$this->avatar,
$this->resource->relationLoaded('avatarMedia') ? $this->resource->getRelation('avatarMedia') : null,
$this->author_name,
),
'client_id' => $this->client_id,
'project_id' => $this->project_id,
'service_id' => $this->service_id,
'is_public' => (bool) $this->is_public,
'position' => $this->position,
'given_at' => $this->given_at?->toDateString(),
'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

@@ -107,6 +107,8 @@ return [
'technologies' => 'technologies', 'technologies' => 'technologies',
'projects' => 'projects', 'projects' => 'projects',
'members' => 'members', 'members' => 'members',
'clients' => 'clients',
'testimonials' => 'testimonials',
], ],
// Fetching from a URL: how long to wait, in seconds. The request that // Fetching from a URL: how long to wait, in seconds. The request that

View File

@@ -29,11 +29,16 @@ return [
'cannot_publish' => 'No tens permís per publicar pàgines.', 'cannot_publish' => 'No tens permís per publicar pàgines.',
], ],
'settings' => [
'read_only' => 'Aquest ajust l\'escriu l\'aplicació i no es pot editar a mà.',
],
'publication' => [ 'publication' => [
'forbidden' => 'No tens permís per publicar ni despublicar això.', 'forbidden' => 'No tens permís per publicar ni despublicar això.',
], ],
'media' => [ 'media' => [
'in_use' => 'La imatge s\'està fent servir: treu-la primer d\'on apareix.',
'not_an_image' => 'El fitxer no és una imatge JPEG, PNG, WebP, GIF, AVIF o SVG.', 'not_an_image' => 'El fitxer no és una imatge JPEG, PNG, WebP, GIF, AVIF o SVG.',
'remote' => [ 'remote' => [
'https_only' => 'La URL de la imatge ha de començar per https://.', 'https_only' => 'La URL de la imatge ha de començar per https://.',

View File

@@ -29,11 +29,16 @@ return [
'cannot_publish' => 'You do not have permission to publish pages.', 'cannot_publish' => 'You do not have permission to publish pages.',
], ],
'settings' => [
'read_only' => 'This setting is written by the application and cannot be edited by hand.',
],
'publication' => [ 'publication' => [
'forbidden' => 'You do not have permission to publish or unpublish this.', 'forbidden' => 'You do not have permission to publish or unpublish this.',
], ],
'media' => [ 'media' => [
'in_use' => 'The image is in use: remove it from where it appears first.',
'not_an_image' => 'The file is not a JPEG, PNG, WebP, GIF, AVIF or SVG image.', 'not_an_image' => 'The file is not a JPEG, PNG, WebP, GIF, AVIF or SVG image.',
'remote' => [ 'remote' => [
'https_only' => 'The image URL must start with https://.', 'https_only' => 'The image URL must start with https://.',

View File

@@ -29,11 +29,16 @@ return [
'cannot_publish' => 'No tienes permiso para publicar páginas.', 'cannot_publish' => 'No tienes permiso para publicar páginas.',
], ],
'settings' => [
'read_only' => 'Este ajuste lo escribe la aplicación y no se puede editar a mano.',
],
'publication' => [ 'publication' => [
'forbidden' => 'No tienes permiso para publicar ni despublicar esto.', 'forbidden' => 'No tienes permiso para publicar ni despublicar esto.',
], ],
'media' => [ 'media' => [
'in_use' => 'La imagen se está usando: quítala antes de donde aparece.',
'not_an_image' => 'El fichero no es una imagen JPEG, PNG, WebP, GIF, AVIF o SVG.', 'not_an_image' => 'El fichero no es una imagen JPEG, PNG, WebP, GIF, AVIF o SVG.',
'remote' => [ 'remote' => [
'https_only' => 'La URL de la imagen tiene que empezar por https://.', 'https_only' => 'La URL de la imagen tiene que empezar por https://.',

File diff suppressed because it is too large Load Diff

View File

@@ -10,15 +10,25 @@ use App\Http\Controllers\Api\PublicApi\V1\ProjectController as PublicProjectCont
use App\Http\Controllers\Api\PublicApi\V1\ShowcaseController; use App\Http\Controllers\Api\PublicApi\V1\ShowcaseController;
use App\Http\Controllers\Api\PublicApi\V1\SiteController; use App\Http\Controllers\Api\PublicApi\V1\SiteController;
use App\Http\Controllers\Api\PublicApi\V1\TelemetryController; use App\Http\Controllers\Api\PublicApi\V1\TelemetryController;
use App\Http\Controllers\Api\V1\ApplicationController as AdminApplicationController;
use App\Http\Controllers\Api\V1\CallController;
use App\Http\Controllers\Api\V1\CategoryController; use App\Http\Controllers\Api\V1\CategoryController;
use App\Http\Controllers\Api\V1\ClientController;
use App\Http\Controllers\Api\V1\MaintenanceController;
use App\Http\Controllers\Api\V1\MeController; use App\Http\Controllers\Api\V1\MeController;
use App\Http\Controllers\Api\V1\MediaController; use App\Http\Controllers\Api\V1\MediaController;
use App\Http\Controllers\Api\V1\MemberController; use App\Http\Controllers\Api\V1\MemberController;
use App\Http\Controllers\Api\V1\MissingUrlController;
use App\Http\Controllers\Api\V1\PageController; use App\Http\Controllers\Api\V1\PageController;
use App\Http\Controllers\Api\V1\ProjectController; use App\Http\Controllers\Api\V1\ProjectController;
use App\Http\Controllers\Api\V1\RedirectController;
use App\Http\Controllers\Api\V1\RepositoryController;
use App\Http\Controllers\Api\V1\RoleController; use App\Http\Controllers\Api\V1\RoleController;
use App\Http\Controllers\Api\V1\ServiceController; use App\Http\Controllers\Api\V1\ServiceController;
use App\Http\Controllers\Api\V1\SettingController;
use App\Http\Controllers\Api\V1\TalkController;
use App\Http\Controllers\Api\V1\TechnologyController; use App\Http\Controllers\Api\V1\TechnologyController;
use App\Http\Controllers\Api\V1\TestimonialController;
use App\Http\Controllers\Api\V1\TokenController; use App\Http\Controllers\Api\V1\TokenController;
use App\Http\Controllers\Api\V1\UserController; use App\Http\Controllers\Api\V1\UserController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -172,12 +182,14 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::apiResource('technologies', TechnologyController::class); Route::apiResource('technologies', TechnologyController::class);
// The collective's work, people and offer. Soft-deleted, so each can // The collective's work, people and offer, and who it was for. Soft-deleted, so each can
// be brought back as a page can. // be brought back as a page can.
foreach ([ foreach ([
'projects' => ProjectController::class, 'projects' => ProjectController::class,
'members' => MemberController::class, 'members' => MemberController::class,
'services' => ServiceController::class, 'services' => ServiceController::class,
'clients' => ClientController::class,
'testimonials' => TestimonialController::class,
] as $resource => $controller) { ] as $resource => $controller) {
$parameter = str($resource)->singular()->toString(); $parameter = str($resource)->singular()->toString();
@@ -187,8 +199,40 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
->name("{$resource}.restore"); ->name("{$resource}.restore");
} }
// Upload only: the file is then referenced from a record by its path. // Deleted for good, like the panel does: neither has a bin.
Route::apiResource('talks', TalkController::class);
Route::apiResource('calls', CallController::class);
// The inbox. Submissions arrive from the public site, never from here.
Route::apiResource('applications', AdminApplicationController::class)
->only(['index', 'show', 'update', 'destroy']);
Route::post('applications/{application}/restore', [AdminApplicationController::class, 'restore'])
->withTrashed()
->name('applications.restore');
// Old addresses, and the missing ones the frontend reported.
Route::apiResource('redirects', RedirectController::class);
Route::apiResource('missing-urls', MissingUrlController::class)->only(['index', 'show', 'update', 'destroy']);
Route::post('missing-urls/{missing_url}/redirect', [MissingUrlController::class, 'redirect'])
->name('missing-urls.redirect');
Route::apiResource('repositories', RepositoryController::class);
Route::post('repositories/{repository}/sync', [RepositoryController::class, 'sync'])
->name('repositories.sync');
// Declared before the settings routes so "maintenance" is never read
// as a setting's key.
Route::get('maintenance', [MaintenanceController::class, 'show'])->name('maintenance.show');
Route::put('maintenance', [MaintenanceController::class, 'update'])->name('maintenance.update');
Route::apiResource('settings', SettingController::class)->only(['index', 'show', 'update']);
// The library. A file is uploaded here and then referenced from a
// record by its path; it is never edited, only processed again.
Route::get('media', [MediaController::class, 'index'])->name('media.index');
Route::post('media', [MediaController::class, 'store'])->name('media.store'); Route::post('media', [MediaController::class, 'store'])->name('media.store');
Route::get('media/{media}', [MediaController::class, 'show'])->name('media.show');
Route::delete('media/{media}', [MediaController::class, 'destroy'])->name('media.destroy');
Route::post('media/{media}/reprocess', [MediaController::class, 'reprocess'])->name('media.reprocess');
Route::apiResource('users', UserController::class)->only(['index', 'show', 'update']); Route::apiResource('users', UserController::class)->only(['index', 'show', 'update']);

View File

@@ -4,13 +4,24 @@ declare(strict_types=1);
namespace Tests\Feature\Api; namespace Tests\Feature\Api;
use App\Models\Application;
use App\Models\Call;
use App\Models\Category; use App\Models\Category;
use App\Models\Client;
use App\Models\Media;
use App\Models\Member; use App\Models\Member;
use App\Models\MissingUrl;
use App\Models\Page; use App\Models\Page;
use App\Models\Project; use App\Models\Project;
use App\Models\Redirect;
use App\Models\Repository;
use App\Models\Service; use App\Models\Service;
use App\Models\Setting;
use App\Models\Talk;
use App\Models\Technology; use App\Models\Technology;
use App\Models\Testimonial;
use App\Models\User; use App\Models\User;
use App\Support\Maintenance;
use Database\Seeders\RolePermissionSeeder; use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
@@ -33,7 +44,7 @@ use Tests\VerifiesOpenApiContract;
* test exercises behaviour, not shape. * test exercises behaviour, not shape.
* *
* Unlike the public listings, an admin index and its matching show endpoint * Unlike the public listings, an admin index and its matching show endpoint
* send the same full record — none of the four resources here has a * send the same full record — none of the resources here has a
* "lightweight for a listing" projection — so both are held to the stricter * "lightweight for a listing" projection — so both are held to the stricter
* rule throughout: everything documented and not `nullable` must be present. * rule throughout: everything documented and not `nullable` must be present.
*/ */
@@ -72,6 +83,15 @@ class AdminApiContractTest extends TestCase
'projects' => ['path' => '/v1/projects'], 'projects' => ['path' => '/v1/projects'],
'members' => ['path' => '/v1/members'], 'members' => ['path' => '/v1/members'],
'services' => ['path' => '/v1/services'], 'services' => ['path' => '/v1/services'],
'clients' => ['path' => '/v1/clients'],
'testimonials' => ['path' => '/v1/testimonials'],
'talks' => ['path' => '/v1/talks'],
'calls' => ['path' => '/v1/calls'],
'applications' => ['path' => '/v1/applications'],
'redirects' => ['path' => '/v1/redirects'],
'missing-urls' => ['path' => '/v1/missing-urls'],
'repositories' => ['path' => '/v1/repositories'],
'media' => ['path' => '/v1/media'],
]; ];
} }
@@ -84,6 +104,16 @@ class AdminApiContractTest extends TestCase
$project = Project::factory()->create(); $project = Project::factory()->create();
$member = Member::factory()->create(); $member = Member::factory()->create();
$service = Service::factory()->create(); $service = Service::factory()->create();
$client = Client::factory()->create();
$testimonial = Testimonial::factory()->create();
$talk = Talk::factory()->create();
$call = Call::factory()->create();
$application = Application::factory()->for($call)->create();
$redirect = Redirect::factory()->create();
$missingUrl = MissingUrl::factory()->create();
$repository = Repository::factory()->create();
Setting::set(Maintenance::KEY, ['enabled' => false]);
$media = Media::factory()->create();
// One request, one documented path — the URL a real caller hits and // One request, one documented path — the URL a real caller hits and
// the path key that describes it, side by side rather than derived // the path key that describes it, side by side rather than derived
@@ -102,6 +132,27 @@ class AdminApiContractTest extends TestCase
'member' => ["/v1/members/{$member->id}", '/v1/members/{member}'], 'member' => ["/v1/members/{$member->id}", '/v1/members/{member}'],
'services' => ['/v1/services', '/v1/services'], 'services' => ['/v1/services', '/v1/services'],
'service' => ["/v1/services/{$service->id}", '/v1/services/{service}'], 'service' => ["/v1/services/{$service->id}", '/v1/services/{service}'],
'clients' => ['/v1/clients', '/v1/clients'],
'client' => ["/v1/clients/{$client->id}", '/v1/clients/{client}'],
'testimonials' => ['/v1/testimonials', '/v1/testimonials'],
'testimonial' => ["/v1/testimonials/{$testimonial->id}", '/v1/testimonials/{testimonial}'],
'talks' => ['/v1/talks', '/v1/talks'],
'talk' => ["/v1/talks/{$talk->id}", '/v1/talks/{talk}'],
'calls' => ['/v1/calls', '/v1/calls'],
'call' => ["/v1/calls/{$call->id}", '/v1/calls/{call}'],
'applications' => ['/v1/applications', '/v1/applications'],
'application' => ["/v1/applications/{$application->id}", '/v1/applications/{application}'],
'redirects' => ['/v1/redirects', '/v1/redirects'],
'redirect' => ["/v1/redirects/{$redirect->id}", '/v1/redirects/{redirect}'],
'missing-urls' => ['/v1/missing-urls', '/v1/missing-urls'],
'missing-url' => ["/v1/missing-urls/{$missingUrl->id}", '/v1/missing-urls/{missing_url}'],
'repositories' => ['/v1/repositories', '/v1/repositories'],
'repository' => ["/v1/repositories/{$repository->id}", '/v1/repositories/{repository}'],
'settings' => ['/v1/settings', '/v1/settings'],
'setting' => ['/v1/settings/'.Maintenance::KEY, '/v1/settings/{setting}'],
'maintenance' => ['/v1/maintenance', '/v1/maintenance'],
'media' => ['/v1/media', '/v1/media'],
'medium' => ["/v1/media/{$media->id}", '/v1/media/{media}'],
'users' => ['/v1/users', '/v1/users'], 'users' => ['/v1/users', '/v1/users'],
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'], 'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
'me' => ['/v1/me', '/v1/me'], 'me' => ['/v1/me', '/v1/me'],

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Enums\ApplicationStatus;
use App\Models\Application;
use App\Models\Call;
use App\Models\Technology;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* Calls for contributors and the inbox they fill, through the admin API.
*/
class CallsAndInboxApiTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
protected function actingWithRole(string $role): User
{
$user = User::factory()->create();
$user->syncRoles([$role]);
$this->withHeaders($this->apiHeadersFor($user));
return $user;
}
#[Test]
public function a_call_is_opened_with_its_stack(): void
{
$this->actingWithRole('editor');
$technology = Technology::factory()->create();
$this->postJson('/api/v1/calls?lang=es', [
'title' => ['es' => 'Backend en Laravel', 'en' => 'Laravel backend'],
'slug' => ['es' => 'backend-laravel', 'en' => 'laravel-backend'],
'hours_per_week' => 4,
'duration_weeks' => 8,
'seniority' => 'mid',
'closes_at' => '2026-12-01',
'technology_ids' => [$technology->id],
])
->assertCreated()
->assertJsonPath('data.title', 'Backend en Laravel')
->assertJsonPath('data.status', 'open')
->assertJsonPath('data.technology_ids', [$technology->id])
->assertJsonPath('data.applications_count', 0);
$this->postJson('/api/v1/calls', [
'title' => ['es' => 'Otra'],
'slug' => ['es' => 'backend-laravel'],
'hours_per_week' => 60,
'seniority' => 'guru',
])
->assertUnprocessable()
->assertJsonValidationErrors(['slug', 'hours_per_week', 'seniority']);
}
#[Test]
public function a_call_is_closed_and_then_deleted(): void
{
$this->actingWithRole('editor');
$call = Call::factory()->create();
$this->patchJson("/api/v1/calls/{$call->id}", ['status' => 'filled'])
->assertOk()
->assertJsonPath('data.status', 'filled');
$this->getJson('/api/v1/calls?status=open')->assertJsonCount(0, 'data');
$this->deleteJson("/api/v1/calls/{$call->id}")->assertNoContent();
$this->assertModelMissing($call);
}
#[Test]
public function the_inbox_leaves_spam_out_unless_asked(): void
{
$this->actingWithRole('editor');
Application::factory()->create(['name' => 'Ada']);
Application::factory()->create(['status' => ApplicationStatus::Spam]);
$this->getJson('/api/v1/applications')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.name', 'Ada')
->assertJsonMissingPath('data.0.ip_hash');
$this->getJson('/api/v1/applications?status=spam')->assertJsonCount(1, 'data');
$this->getJson('/api/v1/applications?search=ada')->assertJsonCount(1, 'data');
}
#[Test]
public function handling_an_application_records_who_did_it(): void
{
$editor = $this->actingWithRole('editor');
$application = Application::factory()->create();
$this->patchJson("/api/v1/applications/{$application->id}", [
'status' => 'contacted',
'notes' => 'Llamada el jueves.',
])
->assertOk()
->assertJsonPath('data.status', 'contacted')
->assertJsonPath('data.notes', 'Llamada el jueves.')
->assertJsonPath('data.handled_by.id', $editor->id);
$this->assertNotNull($application->fresh()->handled_at);
$this->patchJson("/api/v1/applications/{$application->id}", ['status' => 'new'])
->assertJsonPath('data.handled_by', null)
->assertJsonPath('data.handled_at', null);
}
#[Test]
public function what_the_sender_wrote_cannot_be_changed(): void
{
$this->actingWithRole('editor');
$application = Application::factory()->create(['email' => 'real@example.org']);
$this->patchJson("/api/v1/applications/{$application->id}", ['email' => 'fake@example.org', 'status' => 'accepted'])
->assertOk();
$this->assertSame('real@example.org', $application->fresh()->email);
}
#[Test]
public function nobody_creates_an_application_here(): void
{
$this->actingWithRole('admin');
$this->postJson('/api/v1/applications', ['name' => 'X'])->assertMethodNotAllowed();
}
#[Test]
public function a_writer_cannot_read_the_inbox(): void
{
$this->actingWithRole('writer');
$application = Application::factory()->create();
$this->getJson('/api/v1/applications')->assertForbidden();
$this->getJson("/api/v1/applications/{$application->id}")->assertForbidden();
// Calls are theirs to read, like on the panel.
$this->getJson('/api/v1/calls')->assertOk();
}
#[Test]
public function a_deleted_application_can_be_brought_back(): void
{
$this->actingWithRole('editor');
$application = Application::factory()->create();
$this->deleteJson("/api/v1/applications/{$application->id}")->assertNoContent();
$this->assertSoftDeleted($application);
$this->postJson("/api/v1/applications/{$application->id}/restore")->assertOk();
$this->assertNotSoftDeleted($application);
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Jobs\ProcessMedia;
use App\Models\Media;
use App\Models\Page;
use App\Models\Technology;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* The media library through the admin API: what is on the disk, what uses it,
* and deleting what nothing does.
*/
class MediaLibraryApiTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
Storage::fake((string) config('media.disk'));
Queue::fake();
}
protected function actingWithRole(string $role): User
{
$user = User::factory()->create();
$user->syncRoles([$role]);
$this->withHeaders($this->apiHeadersFor($user));
return $user;
}
protected function stored(string $path): Media
{
Storage::disk((string) config('media.disk'))->put($path, 'x');
return Media::for($path);
}
#[Test]
public function the_library_says_what_is_in_use(): void
{
$this->actingWithRole('editor');
$logo = $this->stored('technologies/angular.svg');
$orphan = $this->stored('pages/forgotten.jpg');
Technology::factory()->create(['logo' => $logo->path]);
$this->getJson('/api/v1/media')
->assertOk()
->assertJsonCount(2, 'data')
->assertJsonPath('data.0.path', 'pages/forgotten.jpg')
->assertJsonPath('data.0.referenced', false)
->assertJsonPath('data.1.referenced', true);
$this->getJson('/api/v1/media?orphans=1')
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $orphan->id);
$this->getJson('/api/v1/media?for=technologies')
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $logo->id);
}
#[Test]
public function an_image_nothing_uses_is_deleted_with_its_file(): void
{
$this->actingWithRole('editor');
$orphan = $this->stored('pages/forgotten.jpg');
$this->deleteJson("/api/v1/media/{$orphan->id}")->assertNoContent();
$this->assertModelMissing($orphan);
Storage::disk((string) config('media.disk'))->assertMissing('pages/forgotten.jpg');
}
#[Test]
public function an_image_in_use_is_not_deleted(): void
{
$this->actingWithRole('editor');
$image = $this->stored('pages/hero.jpg');
Page::factory()->create(['featured_image' => $image->path]);
$this->deleteJson("/api/v1/media/{$image->id}")->assertConflict();
$this->assertModelExists($image);
Storage::disk((string) config('media.disk'))->assertExists('pages/hero.jpg');
}
#[Test]
public function a_writer_sees_the_library_but_deletes_nothing(): void
{
$this->actingWithRole('writer');
$orphan = $this->stored('pages/forgotten.jpg');
$this->getJson('/api/v1/media')->assertOk();
$this->getJson("/api/v1/media/{$orphan->id}")->assertOk()->assertJsonPath('data.referenced', false);
$this->deleteJson("/api/v1/media/{$orphan->id}")->assertForbidden();
}
#[Test]
public function an_image_is_processed_again_on_request(): void
{
$this->actingWithRole('editor');
$image = $this->stored('pages/hero.jpg');
Queue::fake();
$this->postJson("/api/v1/media/{$image->id}/reprocess")->assertAccepted();
Queue::assertPushed(ProcessMedia::class);
}
}

View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Models\Client;
use App\Models\Member;
use App\Models\Talk;
use App\Models\Testimonial;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* Clients, testimonials and talks through the admin API: the rest of what the
* landing shows, so it can be loaded without the panel.
*/
class ShowcaseAdminApiTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
protected function actingWithRole(string $role): User
{
$user = User::factory()->create();
$user->syncRoles([$role]);
$this->withHeaders($this->apiHeadersFor($user));
return $user;
}
#[Test]
public function a_client_is_created_with_its_sector_in_several_languages(): void
{
$this->actingWithRole('editor');
$this->postJson('/api/v1/clients?lang=en&with=translations', [
'name' => 'Asociación Vecinal',
'slug' => 'asociacion-vecinal',
'sector' => ['es' => 'Tercer sector', 'en' => 'Non-profit'],
'url' => 'https://vecinos.example.org',
'is_public' => true,
])
->assertCreated()
->assertJsonPath('data.sector', 'Non-profit')
->assertJsonPath('data.translations.sector.es', 'Tercer sector')
->assertJsonPath('data.projects_count', 0);
$this->postJson('/api/v1/clients', ['name' => 'Otra', 'slug' => 'asociacion-vecinal'])
->assertUnprocessable()
->assertJsonValidationErrors('slug');
}
#[Test]
public function a_deleted_client_can_be_brought_back(): void
{
$this->actingWithRole('editor');
$client = Client::factory()->create();
$this->deleteJson("/api/v1/clients/{$client->id}")->assertNoContent();
$this->assertSoftDeleted($client);
$this->postJson("/api/v1/clients/{$client->id}/restore")->assertOk();
$this->assertNotSoftDeleted($client);
}
#[Test]
public function a_testimonial_keeps_the_language_it_was_given_in(): void
{
$this->actingWithRole('editor');
$client = Client::factory()->create();
$this->postJson('/api/v1/testimonials?lang=es', [
'quote' => ['en' => 'They listened first.', 'es' => 'Primero escucharon.'],
'original_locale' => 'en',
'author_name' => 'Grace Hopper',
'author_role' => ['es' => 'Coordinadora'],
'client_id' => $client->id,
'given_at' => '2026-05-01',
])
->assertCreated()
->assertJsonPath('data.quote', 'Primero escucharon.')
->assertJsonPath('data.original_locale', 'en')
->assertJsonPath('data.client_id', $client->id)
->assertJsonPath('data.given_at', '2026-05-01');
$this->assertSame('They listened first.', Testimonial::query()->sole()->getTranslation('quote', 'en'));
$this->postJson('/api/v1/testimonials', [
'quote' => ['es' => 'x'],
'original_locale' => 'tlh',
'author_name' => 'Worf',
])
->assertUnprocessable()
->assertJsonValidationErrors('original_locale');
}
#[Test]
public function a_talk_is_filtered_by_who_gave_it_and_deleted_for_good(): void
{
$this->actingWithRole('editor');
$member = Member::factory()->create();
$id = $this->postJson('/api/v1/talks', [
'title' => 'Angular sin miedo',
'language' => 'es',
'summary' => ['es' => 'Señales y SSR.', 'en' => 'Signals and SSR.'],
'event' => 'Commit Conf',
'held_at' => '2026-04-20',
'member_id' => $member->id,
'video_url' => 'https://video.example.org/1',
])
->assertCreated()
->assertJsonPath('data.title', 'Angular sin miedo')
->json('data.id');
Talk::factory()->create();
$this->getJson("/api/v1/talks?member_id={$member->id}&lang=en")
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.summary', 'Signals and SSR.');
$this->deleteJson("/api/v1/talks/{$id}")->assertNoContent();
$this->assertDatabaseMissing('talks', ['id' => $id]);
}
#[Test]
public function a_writer_can_read_them_but_not_write_them(): void
{
$this->actingWithRole('writer');
$client = Client::factory()->create();
$this->getJson('/api/v1/clients')->assertOk();
$this->getJson('/api/v1/testimonials')->assertOk();
$this->getJson('/api/v1/talks')->assertOk();
$this->patchJson("/api/v1/clients/{$client->id}", ['name' => 'Otro'])->assertForbidden();
$this->postJson('/api/v1/talks', ['title' => 'X', 'language' => 'es', 'event' => 'Y'])->assertForbidden();
}
#[Test]
public function a_logo_is_uploaded_for_a_client(): void
{
$this->actingWithRole('writer');
// Uploading for a client takes permission to write clients.
$this->postJson('/api/v1/media', ['url' => 'https://example.org/a.png', 'for' => 'clients'])
->assertForbidden();
}
}

View File

@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Enums\MissingUrlStatus;
use App\Jobs\SyncRepository;
use App\Models\MissingUrl;
use App\Models\Repository;
use App\Models\Setting;
use App\Models\User;
use App\Support\ContentVersion;
use App\Support\Maintenance;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* Redirects, missing URLs, repositories, settings and maintenance through the
* admin API — the panel's administration screens.
*/
class SiteAdministrationApiTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
protected function actingWithRole(string $role): User
{
$user = User::factory()->create();
$user->syncRoles([$role]);
$this->withHeaders($this->apiHeadersFor($user));
return $user;
}
#[Test]
public function a_redirect_is_normalised_and_closes_the_report_it_answers(): void
{
$this->actingWithRole('editor');
MissingUrl::report('/proyectos/viejo', 'es');
$this->postJson('/api/v1/redirects', [
'from' => 'proyectos/viejo/',
'to' => '/proyectos/nuevo?x=1',
])
->assertCreated()
->assertJsonPath('data.from', '/proyectos/viejo')
->assertJsonPath('data.to', '/proyectos/nuevo')
->assertJsonPath('data.status', 301)
->assertJsonPath('data.automatic', false);
$this->assertSame(MissingUrlStatus::Resolved, MissingUrl::query()->sole()->status);
// The same address in the same language is one rule, not two.
$this->postJson('/api/v1/redirects', ['from' => '/proyectos/viejo', 'to' => '/otra'])
->assertUnprocessable()
->assertJsonValidationErrors('from');
$this->postJson('/api/v1/redirects', ['from' => '/a', 'to' => '/a/'])
->assertUnprocessable()
->assertJsonValidationErrors('to');
}
#[Test]
public function a_missing_url_is_given_a_destination(): void
{
$this->actingWithRole('editor');
MissingUrl::report('/en/old', 'en');
$missing = MissingUrl::query()->sole();
$this->getJson('/api/v1/missing-urls')->assertJsonCount(1, 'data');
$this->postJson("/api/v1/missing-urls/{$missing->id}/redirect", ['to' => '/en/new'])
->assertCreated()
->assertJsonPath('data.from', '/en/old')
->assertJsonPath('data.locale', 'en');
$this->assertSame(MissingUrlStatus::Resolved, $missing->fresh()->status);
$this->getJson('/api/v1/missing-urls')->assertJsonCount(0, 'data');
$this->postJson("/api/v1/missing-urls/{$missing->id}/redirect", ['to' => '/en/other'])
->assertUnprocessable()
->assertJsonValidationErrors('locale');
}
#[Test]
public function a_missing_url_can_be_ignored_but_not_declared_resolved(): void
{
$this->actingWithRole('editor');
$missing = MissingUrl::factory()->create();
$this->patchJson("/api/v1/missing-urls/{$missing->id}", ['status' => 'ignored'])
->assertOk()
->assertJsonPath('data.status', 'ignored');
$this->patchJson("/api/v1/missing-urls/{$missing->id}", ['status' => 'resolved'])
->assertUnprocessable();
}
#[Test]
public function a_repository_is_added_and_synced_on_the_queue(): void
{
Queue::fake();
$this->actingWithRole('admin');
$id = $this->postJson('/api/v1/repositories', [
'provider' => 'github',
'full_name' => 'pireon/landing',
'show_publicly' => true,
// Synced columns are not the caller's to set.
'stars' => 9999,
])
->assertCreated()
->assertJsonPath('data.full_name', 'pireon/landing')
->assertJsonPath('data.stars', 0)
->json('data.id');
$this->postJson('/api/v1/repositories', ['provider' => 'github', 'full_name' => 'pireon/landing'])
->assertUnprocessable()
->assertJsonValidationErrors('full_name');
$this->postJson("/api/v1/repositories/{$id}/sync")->assertAccepted();
Queue::assertPushed(SyncRepository::class);
}
#[Test]
public function an_editor_reads_repositories_but_cannot_sync_them(): void
{
Queue::fake();
$this->actingWithRole('editor');
$repository = Repository::factory()->create();
$this->getJson('/api/v1/repositories')->assertOk();
$this->postJson("/api/v1/repositories/{$repository->id}/sync")->assertForbidden();
Queue::assertNothingPushed();
}
#[Test]
public function a_setting_is_replaced_whole(): void
{
$this->actingWithRole('admin');
Setting::set('site.contact', ['email' => 'hola@example.org']);
$this->getJson('/api/v1/settings')->assertOk()->assertJsonPath('data.0.key', 'site.contact');
$this->patchJson('/api/v1/settings/site.contact', ['value' => ['email' => 'nuevo@example.org']])
->assertOk()
->assertJsonPath('data.value.email', 'nuevo@example.org');
$this->assertSame(['email' => 'nuevo@example.org'], Setting::get('site.contact'));
}
#[Test]
public function the_content_version_is_not_edited_by_hand(): void
{
$this->actingWithRole('admin');
ContentVersion::bump();
$this->patchJson('/api/v1/settings/'.ContentVersion::KEY, ['value' => 1])->assertForbidden();
}
#[Test]
public function an_editor_sees_settings_but_changes_none(): void
{
$this->actingWithRole('editor');
Setting::set(Maintenance::KEY, ['enabled' => false]);
$this->getJson('/api/v1/settings')->assertOk();
$this->getJson('/api/v1/maintenance')->assertOk()->assertJsonPath('data.enabled', false);
$this->patchJson('/api/v1/settings/'.Maintenance::KEY, ['value' => ['enabled' => true]])->assertForbidden();
$this->putJson('/api/v1/maintenance', ['enabled' => true])->assertForbidden();
$this->assertFalse(Maintenance::isEnabled());
}
#[Test]
public function maintenance_is_switched_on_and_off_keeping_its_wording(): void
{
$this->actingWithRole('admin');
$this->putJson('/api/v1/maintenance', [
'enabled' => true,
'message' => ['es' => 'Volvemos enseguida.', 'en' => ''],
'until' => now()->addHour()->toIso8601String(),
])
->assertOk()
->assertJsonPath('data.enabled', true)
->assertJsonPath('data.message', ['es' => 'Volvemos enseguida.']);
$this->assertTrue(Maintenance::isEnabled());
$this->putJson('/api/v1/maintenance', ['enabled' => false])
->assertOk()
->assertJsonPath('data.enabled', false)
->assertJsonPath('data.until', null)
->assertJsonPath('data.message.es', 'Volvemos enseguida.');
}
}