Compare commits
4 Commits
96747abdc3
...
4c4a2b3299
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c4a2b3299 | |||
| 8d0f2e3639 | |||
| 4c65604f04 | |||
| b02464a7ed |
36
README.md
36
README.md
@@ -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.
|
||||||
|
|
||||||
|
|||||||
104
app/Http/Controllers/Api/V1/ApplicationController.php
Normal file
104
app/Http/Controllers/Api/V1/ApplicationController.php
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\ApplicationStatus;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\ApplicationRequest;
|
||||||
|
use App\Http\Resources\ApplicationResource;
|
||||||
|
use App\Models\Application;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inbox: what people sent from the public site.
|
||||||
|
*
|
||||||
|
* Nothing is created here — submissions arrive through the public API — and
|
||||||
|
* what can be changed is what the panel changes: the status and the private
|
||||||
|
* notes.
|
||||||
|
*/
|
||||||
|
class ApplicationController extends Controller
|
||||||
|
{
|
||||||
|
protected const WITH = ['call', 'handler'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: status, kind, call_id, search (name, e-mail or organisation), trashed.
|
||||||
|
*
|
||||||
|
* Spam stays out unless asked for by `status=spam`, as in the panel.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Application::class);
|
||||||
|
|
||||||
|
$applications = Application::query()
|
||||||
|
->with(self::WITH)
|
||||||
|
->when(
|
||||||
|
$request->filled('status'),
|
||||||
|
fn ($q) => $q->where('status', $request->string('status')),
|
||||||
|
fn ($q) => $q->notSpam(),
|
||||||
|
)
|
||||||
|
->when($request->filled('kind'), fn ($q) => $q->where('kind', $request->string('kind')))
|
||||||
|
->when($request->filled('call_id'), fn ($q) => $q->where('call_id', $request->integer('call_id')))
|
||||||
|
->when($request->filled('search'), function ($q) use ($request): void {
|
||||||
|
$term = '%'.$request->string('search').'%';
|
||||||
|
|
||||||
|
$q->where(fn ($q) => $q
|
||||||
|
->where('name', 'like', $term)
|
||||||
|
->orWhere('email', 'like', $term)
|
||||||
|
->orWhere('organisation', 'like', $term));
|
||||||
|
})
|
||||||
|
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
|
||||||
|
->latest()
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return ApplicationResource::collection($applications);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Application $application): ApplicationResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $application);
|
||||||
|
|
||||||
|
return ApplicationResource::make($application->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(ApplicationRequest $request, Application $application): ApplicationResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $application);
|
||||||
|
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
// Notes are outside the model's fillable list so the public form can
|
||||||
|
// never reach them; this is the one other place that writes them.
|
||||||
|
if (array_key_exists('notes', $data)) {
|
||||||
|
$application->forceFill(['notes' => $data['notes']])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['status'])) {
|
||||||
|
$application->markAs(ApplicationStatus::from($data['status']), $request->user());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApplicationResource::make($application->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Application $application): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $application);
|
||||||
|
|
||||||
|
$application->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(Application $application): ApplicationResource
|
||||||
|
{
|
||||||
|
$this->authorize('restore', $application);
|
||||||
|
|
||||||
|
$application->restore();
|
||||||
|
|
||||||
|
return ApplicationResource::make($application->load(self::WITH));
|
||||||
|
}
|
||||||
|
}
|
||||||
95
app/Http/Controllers/Api/V1/CallController.php
Normal file
95
app/Http/Controllers/Api/V1/CallController.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\CallRequest;
|
||||||
|
use App\Http\Resources\CallResource;
|
||||||
|
use App\Models\Call;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open calls for contributors. Deleted for good, like the panel does.
|
||||||
|
*/
|
||||||
|
class CallController extends Controller
|
||||||
|
{
|
||||||
|
protected const WITH = ['technologies'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: status, project_id, search (title in the active language).
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Call::class);
|
||||||
|
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
|
||||||
|
$calls = Call::query()
|
||||||
|
->with(self::WITH)
|
||||||
|
->withCount('applications')
|
||||||
|
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
|
||||||
|
->when($request->filled('project_id'), fn ($q) => $q->where('project_id', $request->integer('project_id')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where("title->{$locale}", 'like', '%'.$request->string('search').'%'))
|
||||||
|
->latest('id')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return CallResource::collection($calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(CallRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Call::class);
|
||||||
|
|
||||||
|
return CallResource::make($this->save(new Call, $request))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Call $call): CallResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $call);
|
||||||
|
|
||||||
|
return CallResource::make($call->load(self::WITH)->loadCount('applications'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(CallRequest $request, Call $call): CallResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $call);
|
||||||
|
|
||||||
|
return CallResource::make($this->save($call, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Call $call): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $call);
|
||||||
|
|
||||||
|
$call->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function save(Call $call, CallRequest $request): Call
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($call, $data): void {
|
||||||
|
$call->fill(TranslatablePayload::merge(
|
||||||
|
$call,
|
||||||
|
collect($data)->except(['technology_ids'])->all(),
|
||||||
|
$call->translatable,
|
||||||
|
));
|
||||||
|
$call->save();
|
||||||
|
|
||||||
|
if (array_key_exists('technology_ids', $data)) {
|
||||||
|
$call->technologies()->sync($data['technology_ids']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $call->load(self::WITH)->loadCount('applications');
|
||||||
|
}
|
||||||
|
}
|
||||||
90
app/Http/Controllers/Api/V1/ClientController.php
Normal file
90
app/Http/Controllers/Api/V1/ClientController.php
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\ClientRequest;
|
||||||
|
use App\Http\Resources\ClientResource;
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The organisations the work was for, as the panel edits them.
|
||||||
|
*/
|
||||||
|
class ClientController extends Controller
|
||||||
|
{
|
||||||
|
protected const WITH = ['logoMedia'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: is_public, search (name), trashed.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Client::class);
|
||||||
|
|
||||||
|
$clients = Client::query()
|
||||||
|
->with(self::WITH)
|
||||||
|
->withCount('projects')
|
||||||
|
->when($request->has('is_public'), fn ($q) => $q->where('is_public', $request->boolean('is_public')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where('name', 'like', '%'.$request->string('search').'%'))
|
||||||
|
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
|
||||||
|
->orderBy('position')
|
||||||
|
->orderBy('name')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return ClientResource::collection($clients);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(ClientRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Client::class);
|
||||||
|
|
||||||
|
return ClientResource::make($this->save(new Client, $request))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Client $client): ClientResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $client);
|
||||||
|
|
||||||
|
return ClientResource::make($client->load(self::WITH)->loadCount('projects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(ClientRequest $request, Client $client): ClientResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $client);
|
||||||
|
|
||||||
|
return ClientResource::make($this->save($client, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Client $client): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $client);
|
||||||
|
|
||||||
|
$client->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(Client $client): ClientResource
|
||||||
|
{
|
||||||
|
$this->authorize('restore', $client);
|
||||||
|
|
||||||
|
$client->restore();
|
||||||
|
|
||||||
|
return ClientResource::make($client->load(self::WITH)->loadCount('projects'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function save(Client $client, ClientRequest $request): Client
|
||||||
|
{
|
||||||
|
$client->fill(TranslatablePayload::merge($client, $request->validated(), $client->translatable));
|
||||||
|
$client->save();
|
||||||
|
|
||||||
|
return $client->load(self::WITH)->loadCount('projects');
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Http/Controllers/Api/V1/MaintenanceController.php
Normal file
68
app/Http/Controllers/Api/V1/MaintenanceController.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Support\Maintenance;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public site's maintenance mode — the panel's maintenance screen, as two
|
||||||
|
* calls. Only the public site goes down; the panel and this API keep working.
|
||||||
|
*/
|
||||||
|
class MaintenanceController extends Controller
|
||||||
|
{
|
||||||
|
public function show(): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('settings.view');
|
||||||
|
|
||||||
|
return $this->state();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `enabled` switches it; `message` (language → text) and `until` are kept
|
||||||
|
* either way, so the wording can be prepared before the site goes down.
|
||||||
|
*/
|
||||||
|
public function update(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('settings.maintenance');
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'enabled' => ['required', 'boolean'],
|
||||||
|
'message' => ['sometimes', 'nullable', 'array'],
|
||||||
|
'message.*' => ['nullable', 'string', 'max:500'],
|
||||||
|
'until' => ['sometimes', 'nullable', 'date', 'after:now'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$message = array_key_exists('message', $data)
|
||||||
|
? array_filter((array) $data['message'], fn ($text) => filled($text))
|
||||||
|
: Maintenance::messages();
|
||||||
|
|
||||||
|
if ($data['enabled']) {
|
||||||
|
Maintenance::enable(
|
||||||
|
message: $message,
|
||||||
|
until: filled($data['until'] ?? null) ? CarbonImmutable::parse($data['until']) : null,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Switching it off must not take the prepared wording with it.
|
||||||
|
Setting::set(Maintenance::KEY, [...Maintenance::state(), 'enabled' => false, 'until' => null, 'message' => $message]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->state();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function state(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(['data' => [
|
||||||
|
'enabled' => Maintenance::isEnabled(),
|
||||||
|
'message' => (object) Maintenance::messages(),
|
||||||
|
'until' => Maintenance::until()?->toIso8601String(),
|
||||||
|
'started_at' => Maintenance::startedAt()?->toIso8601String(),
|
||||||
|
]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
95
app/Http/Controllers/Api/V1/MissingUrlController.php
Normal file
95
app/Http/Controllers/Api/V1/MissingUrlController.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\MissingUrlStatus;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\MissingUrlRedirectRequest;
|
||||||
|
use App\Http\Resources\MissingUrlResource;
|
||||||
|
use App\Http\Resources\RedirectResource;
|
||||||
|
use App\Models\MissingUrl;
|
||||||
|
use App\Models\Redirect;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Addresses the public site was asked for and could not find, reported by the
|
||||||
|
* frontend. What can be done with one is what the panel does: ignore it, or
|
||||||
|
* give it a destination.
|
||||||
|
*/
|
||||||
|
class MissingUrlController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Filters: status (pending by default), locale, search (path).
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', MissingUrl::class);
|
||||||
|
|
||||||
|
$missing = MissingUrl::query()
|
||||||
|
->where('status', $request->string('status', MissingUrlStatus::Pending->value))
|
||||||
|
->when($request->filled('locale'), fn ($q) => $q->where('locale', $request->string('locale')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where('path', 'like', '%'.$request->string('search').'%'))
|
||||||
|
->orderByDesc('hits')
|
||||||
|
->orderByDesc('last_seen_at')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return MissingUrlResource::collection($missing);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(MissingUrl $missingUrl): MissingUrlResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $missingUrl);
|
||||||
|
|
||||||
|
return MissingUrlResource::make($missingUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only between pending and ignored: `resolved` is what writing a redirect
|
||||||
|
* makes it, and claiming it without one would be a lie the next sighting
|
||||||
|
* undoes anyway.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, MissingUrl $missingUrl): MissingUrlResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $missingUrl);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'status' => ['required', Rule::in([MissingUrlStatus::Pending->value, MissingUrlStatus::Ignored->value])],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$missingUrl->update($data);
|
||||||
|
|
||||||
|
return MissingUrlResource::make($missingUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(MissingUrl $missingUrl): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $missingUrl);
|
||||||
|
|
||||||
|
$missingUrl->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give the address a destination. The report is marked resolved by the
|
||||||
|
* redirect being created, as from anywhere else.
|
||||||
|
*/
|
||||||
|
public function redirect(MissingUrlRedirectRequest $request, MissingUrl $missingUrl): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('redirect', $missingUrl);
|
||||||
|
|
||||||
|
$redirect = Redirect::create([
|
||||||
|
'from' => $missingUrl->path,
|
||||||
|
'is_enabled' => true,
|
||||||
|
...$request->validated(),
|
||||||
|
])->refresh();
|
||||||
|
|
||||||
|
return RedirectResource::make($redirect)->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
app/Http/Controllers/Api/V1/RedirectController.php
Normal file
77
app/Http/Controllers/Api/V1/RedirectController.php
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\RedirectRequest;
|
||||||
|
use App\Http\Resources\RedirectResource;
|
||||||
|
use App\Models\Redirect;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Old addresses and where they go now.
|
||||||
|
*/
|
||||||
|
class RedirectController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Filters: is_enabled, locale, automatic (left behind by a slug rename),
|
||||||
|
* search (either end).
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Redirect::class);
|
||||||
|
|
||||||
|
$redirects = Redirect::query()
|
||||||
|
->when($request->has('is_enabled'), fn ($q) => $q->where('is_enabled', $request->boolean('is_enabled')))
|
||||||
|
->when($request->filled('locale'), fn ($q) => $q->where('locale', $request->string('locale')))
|
||||||
|
->when($request->has('automatic'), fn ($q) => $request->boolean('automatic')
|
||||||
|
? $q->whereNotNull('source_type')
|
||||||
|
: $q->whereNull('source_type'))
|
||||||
|
->when($request->filled('search'), function ($q) use ($request): void {
|
||||||
|
$term = '%'.$request->string('search').'%';
|
||||||
|
|
||||||
|
$q->where(fn ($q) => $q->where('from', 'like', $term)->orWhere('to', 'like', $term));
|
||||||
|
})
|
||||||
|
->latest()
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return RedirectResource::collection($redirects);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(RedirectRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Redirect::class);
|
||||||
|
|
||||||
|
return RedirectResource::make(Redirect::create($request->validated())->refresh())->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Redirect $redirect): RedirectResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $redirect);
|
||||||
|
|
||||||
|
return RedirectResource::make($redirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(RedirectRequest $request, Redirect $redirect): RedirectResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $redirect);
|
||||||
|
|
||||||
|
$redirect->update($request->validated());
|
||||||
|
|
||||||
|
return RedirectResource::make($redirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Redirect $redirect): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $redirect);
|
||||||
|
|
||||||
|
$redirect->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
app/Http/Controllers/Api/V1/RepositoryController.php
Normal file
87
app/Http/Controllers/Api/V1/RepositoryController.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\RepositoryRequest;
|
||||||
|
use App\Http\Resources\RepositoryResource;
|
||||||
|
use App\Jobs\SyncRepository;
|
||||||
|
use App\Models\Repository;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The collective's git repositories. Only which repository, whose project and
|
||||||
|
* whether to show it are written here; everything else is what the provider
|
||||||
|
* last reported.
|
||||||
|
*/
|
||||||
|
class RepositoryController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Filters: provider, project_id, show_publicly, failing, search (full name).
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Repository::class);
|
||||||
|
|
||||||
|
$repositories = Repository::query()
|
||||||
|
->when($request->filled('provider'), fn ($q) => $q->where('provider', $request->string('provider')))
|
||||||
|
->when($request->filled('project_id'), fn ($q) => $q->where('project_id', $request->integer('project_id')))
|
||||||
|
->when($request->has('show_publicly'), fn ($q) => $q->where('show_publicly', $request->boolean('show_publicly')))
|
||||||
|
->when($request->boolean('failing'), fn ($q) => $q->failing())
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where('full_name', 'like', '%'.$request->string('search').'%'))
|
||||||
|
->orderBy('full_name')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return RepositoryResource::collection($repositories);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(RepositoryRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Repository::class);
|
||||||
|
|
||||||
|
return RepositoryResource::make(Repository::create($request->validated())->refresh())->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Repository $repository): RepositoryResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $repository);
|
||||||
|
|
||||||
|
return RepositoryResource::make($repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(RepositoryRequest $request, Repository $repository): RepositoryResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $repository);
|
||||||
|
|
||||||
|
$repository->update($request->validated());
|
||||||
|
|
||||||
|
return RepositoryResource::make($repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Repository $repository): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $repository);
|
||||||
|
|
||||||
|
$repository->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the provider again, on the queue: a sync spends a rate limit the
|
||||||
|
* whole application shares, and a caller should not wait on it.
|
||||||
|
*/
|
||||||
|
public function sync(Repository $repository): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('sync', $repository);
|
||||||
|
|
||||||
|
SyncRepository::dispatch($repository);
|
||||||
|
|
||||||
|
return RepositoryResource::make($repository)->response()->setStatusCode(202);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Http/Controllers/Api/V1/SettingController.php
Normal file
56
app/Http/Controllers/Api/V1/SettingController.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\SettingResource;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Support\ContentVersion;
|
||||||
|
use App\Support\Maintenance;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime settings, as the panel's settings screen shows them: the ones that
|
||||||
|
* exist, each value as the JSON it is. None is created or deleted from here.
|
||||||
|
*/
|
||||||
|
class SettingController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Written by the application, never by hand: a hand-set content version
|
||||||
|
* would leave the frontend's cache serving stale pages or refetching them
|
||||||
|
* for nothing.
|
||||||
|
*/
|
||||||
|
protected const READ_ONLY = [ContentVersion::KEY];
|
||||||
|
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('settings.view');
|
||||||
|
|
||||||
|
return SettingResource::collection(Setting::query()->orderBy('key')->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Setting $setting): SettingResource
|
||||||
|
{
|
||||||
|
$this->authorize('settings.view');
|
||||||
|
|
||||||
|
return SettingResource::make($setting);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Setting $setting): SettingResource
|
||||||
|
{
|
||||||
|
// Taking the site down is its own permission, in the panel as here.
|
||||||
|
$this->authorize($setting->key === Maintenance::KEY ? 'settings.maintenance' : 'settings.update');
|
||||||
|
|
||||||
|
abort_if(in_array($setting->key, self::READ_ONLY, true), 403, __('rules.settings.read_only'));
|
||||||
|
|
||||||
|
// `present` rather than `required`: null is a value a setting may hold.
|
||||||
|
$request->validate(['value' => ['present']]);
|
||||||
|
|
||||||
|
$setting->update(['value' => $request->input('value')]);
|
||||||
|
|
||||||
|
return SettingResource::make($setting);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/Http/Controllers/Api/V1/TalkController.php
Normal file
81
app/Http/Controllers/Api/V1/TalkController.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\TalkRequest;
|
||||||
|
use App\Http\Resources\TalkResource;
|
||||||
|
use App\Models\Talk;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talks given elsewhere. Deleted for good: there is no bin for them.
|
||||||
|
*/
|
||||||
|
class TalkController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Filters: is_public, member_id, project_id, search (title or event).
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Talk::class);
|
||||||
|
|
||||||
|
$talks = Talk::query()
|
||||||
|
->when($request->has('is_public'), fn ($q) => $q->where('is_public', $request->boolean('is_public')))
|
||||||
|
->when($request->filled('member_id'), fn ($q) => $q->where('member_id', $request->integer('member_id')))
|
||||||
|
->when($request->filled('project_id'), fn ($q) => $q->where('project_id', $request->integer('project_id')))
|
||||||
|
->when($request->filled('search'), function ($q) use ($request): void {
|
||||||
|
$term = '%'.$request->string('search').'%';
|
||||||
|
|
||||||
|
$q->where(fn ($q) => $q->where('title', 'like', $term)->orWhere('event', 'like', $term));
|
||||||
|
})
|
||||||
|
->recentFirst()
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return TalkResource::collection($talks);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(TalkRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Talk::class);
|
||||||
|
|
||||||
|
return TalkResource::make($this->save(new Talk, $request))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Talk $talk): TalkResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $talk);
|
||||||
|
|
||||||
|
return TalkResource::make($talk);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(TalkRequest $request, Talk $talk): TalkResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $talk);
|
||||||
|
|
||||||
|
return TalkResource::make($this->save($talk, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Talk $talk): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $talk);
|
||||||
|
|
||||||
|
$talk->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function save(Talk $talk, TalkRequest $request): Talk
|
||||||
|
{
|
||||||
|
$talk->fill(TranslatablePayload::merge($talk, $request->validated(), $talk->translatable));
|
||||||
|
$talk->save();
|
||||||
|
|
||||||
|
return $talk;
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/Http/Controllers/Api/V1/TestimonialController.php
Normal file
92
app/Http/Controllers/Api/V1/TestimonialController.php
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\TestimonialRequest;
|
||||||
|
use App\Http\Resources\TestimonialResource;
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What people the collective worked with said about it.
|
||||||
|
*/
|
||||||
|
class TestimonialController extends Controller
|
||||||
|
{
|
||||||
|
protected const WITH = ['avatarMedia'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: is_public, client_id, project_id, service_id, search (author), trashed.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Testimonial::class);
|
||||||
|
|
||||||
|
$testimonials = Testimonial::query()
|
||||||
|
->with(self::WITH)
|
||||||
|
->when($request->has('is_public'), fn ($q) => $q->where('is_public', $request->boolean('is_public')))
|
||||||
|
->when($request->filled('client_id'), fn ($q) => $q->where('client_id', $request->integer('client_id')))
|
||||||
|
->when($request->filled('project_id'), fn ($q) => $q->where('project_id', $request->integer('project_id')))
|
||||||
|
->when($request->filled('service_id'), fn ($q) => $q->where('service_id', $request->integer('service_id')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where('author_name', 'like', '%'.$request->string('search').'%'))
|
||||||
|
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
|
||||||
|
->orderBy('position')
|
||||||
|
->latest('id')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return TestimonialResource::collection($testimonials);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(TestimonialRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Testimonial::class);
|
||||||
|
|
||||||
|
return TestimonialResource::make($this->save(new Testimonial, $request))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Testimonial $testimonial): TestimonialResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $testimonial);
|
||||||
|
|
||||||
|
return TestimonialResource::make($testimonial->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(TestimonialRequest $request, Testimonial $testimonial): TestimonialResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $testimonial);
|
||||||
|
|
||||||
|
return TestimonialResource::make($this->save($testimonial, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Testimonial $testimonial): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $testimonial);
|
||||||
|
|
||||||
|
$testimonial->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(Testimonial $testimonial): TestimonialResource
|
||||||
|
{
|
||||||
|
$this->authorize('restore', $testimonial);
|
||||||
|
|
||||||
|
$testimonial->restore();
|
||||||
|
|
||||||
|
return TestimonialResource::make($testimonial->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function save(Testimonial $testimonial, TestimonialRequest $request): Testimonial
|
||||||
|
{
|
||||||
|
$testimonial->fill(TranslatablePayload::merge($testimonial, $request->validated(), $testimonial->translatable));
|
||||||
|
$testimonial->save();
|
||||||
|
|
||||||
|
return $testimonial->load(self::WITH);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Http/Requests/Api/V1/ApplicationRequest.php
Normal file
32
app/Http/Requests/Api/V1/ApplicationRequest.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\ApplicationStatus;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the inbox lets anyone change about a submission: nothing the sender
|
||||||
|
* wrote, only how it is being handled.
|
||||||
|
*/
|
||||||
|
class ApplicationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => ['sometimes', Rule::enum(ApplicationStatus::class)],
|
||||||
|
'notes' => ['sometimes', 'nullable', 'string', 'max:10000'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Http/Requests/Api/V1/CallRequest.php
Normal file
58
app/Http/Requests/Api/V1/CallRequest.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\CallStatus;
|
||||||
|
use App\Models\Call;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use App\Rules\UniqueTranslatedSlug;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's call form, as rules. `technology_ids` is replaced whole when
|
||||||
|
* sent.
|
||||||
|
*/
|
||||||
|
class CallRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public const SENIORITIES = ['any', 'junior', 'mid', 'senior'];
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$call = $this->route('call');
|
||||||
|
$creating = ! $call instanceof Call;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => [$sometimes, new Translatable(requireDefaultLocale: $creating, max: 255)],
|
||||||
|
'slug' => [
|
||||||
|
$sometimes,
|
||||||
|
new Translatable(requireDefaultLocale: $creating, max: 255),
|
||||||
|
new UniqueTranslatedSlug(Call::class, $call?->id),
|
||||||
|
],
|
||||||
|
'description' => ['sometimes', 'nullable', new Translatable],
|
||||||
|
'what_you_get' => ['sometimes', 'nullable', new Translatable],
|
||||||
|
|
||||||
|
'status' => ['sometimes', Rule::enum(CallStatus::class)],
|
||||||
|
'hours_per_week' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:40'],
|
||||||
|
'duration_weeks' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||||
|
'seniority' => ['sometimes', 'nullable', Rule::in(self::SENIORITIES)],
|
||||||
|
'opens_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'closes_at' => ['sometimes', 'nullable', 'date', 'after_or_equal:opens_at'],
|
||||||
|
|
||||||
|
'project_id' => ['sometimes', 'nullable', 'integer', Rule::exists('projects', 'id')->whereNull('deleted_at')],
|
||||||
|
'technology_ids' => ['sometimes', 'array'],
|
||||||
|
'technology_ids.*' => ['integer', 'distinct', Rule::exists('technologies', 'id')],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Http/Requests/Api/V1/ClientRequest.php
Normal file
47
app/Http/Requests/Api/V1/ClientRequest.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's client form, as rules.
|
||||||
|
*/
|
||||||
|
class ClientRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$client = $this->route('client');
|
||||||
|
$creating = ! $client instanceof Client;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => [$sometimes, 'string', 'max:255'],
|
||||||
|
'slug' => [
|
||||||
|
$sometimes,
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
'alpha_dash:ascii',
|
||||||
|
Rule::unique('clients', 'slug')->ignore($client?->id),
|
||||||
|
],
|
||||||
|
'sector' => ['sometimes', 'nullable', new Translatable(max: 120)],
|
||||||
|
'logo' => ['sometimes', 'nullable', 'string', 'max:2048'],
|
||||||
|
'url' => ['sometimes', 'nullable', 'url', 'max:2048'],
|
||||||
|
'is_public' => ['sometimes', 'boolean'],
|
||||||
|
'position' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Http/Requests/Api/V1/MissingUrlRedirectRequest.php
Normal file
68
app/Http/Requests/Api/V1/MissingUrlRedirectRequest.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\MissingUrl;
|
||||||
|
use App\Models\Redirect;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a missing address should go. The origin is the address itself.
|
||||||
|
*/
|
||||||
|
class MissingUrlRedirectRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if (is_string($this->input('to')) && filled($this->input('to'))) {
|
||||||
|
$this->merge(['to' => Redirect::normalise($this->input('to'))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The language it was seen in, unless told otherwise; null for all.
|
||||||
|
if (! $this->exists('locale')) {
|
||||||
|
$this->merge(['locale' => $this->missingUrl()->locale]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$path = $this->missingUrl()->path;
|
||||||
|
$locale = $this->input('locale');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'to' => ['required', 'string', 'max:500', Rule::notIn([$path])],
|
||||||
|
'status' => ['sometimes', 'integer', Rule::in([301, 302])],
|
||||||
|
'locale' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
Rule::in(array_keys((array) config('locales.supported'))),
|
||||||
|
// Already answered by a rule: edit that one instead.
|
||||||
|
function (string $attribute, mixed $value, \Closure $fail) use ($path, $locale): void {
|
||||||
|
$exists = Redirect::query()
|
||||||
|
->where('from', $path)
|
||||||
|
->when($locale === null, fn ($q) => $q->whereNull('locale'), fn ($q) => $q->where('locale', $locale))
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
$fail(__('validation.unique', ['attribute' => 'from']));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function missingUrl(): MissingUrl
|
||||||
|
{
|
||||||
|
return $this->route('missing_url');
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/Http/Requests/Api/V1/RedirectRequest.php
Normal file
67
app/Http/Requests/Api/V1/RedirectRequest.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Redirect;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's redirect form, as rules. Both ends are normalised before they
|
||||||
|
* are checked, so `/a/` and `/a` are the same rule however it was typed.
|
||||||
|
*/
|
||||||
|
class RedirectRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
foreach (['from', 'to'] as $end) {
|
||||||
|
if (is_string($this->input($end)) && filled($this->input($end))) {
|
||||||
|
$this->merge([$end => Redirect::normalise($this->input($end))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$redirect = $this->route('redirect');
|
||||||
|
$creating = ! $redirect instanceof Redirect;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
$from = $this->input('from', $redirect?->from);
|
||||||
|
$locale = $this->exists('locale') ? $this->input('locale') : $redirect?->locale;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'from' => [
|
||||||
|
$sometimes,
|
||||||
|
'string',
|
||||||
|
'max:500',
|
||||||
|
// One rule per address and language, as the table's index says.
|
||||||
|
Rule::unique('redirects', 'from')
|
||||||
|
->ignore($redirect?->id)
|
||||||
|
->where(fn ($q) => $locale === null ? $q->whereNull('locale') : $q->where('locale', $locale)),
|
||||||
|
],
|
||||||
|
'to' => [$sometimes, 'string', 'max:500', Rule::notIn([$from])],
|
||||||
|
'status' => ['sometimes', 'integer', Rule::in([301, 302])],
|
||||||
|
'locale' => ['sometimes', 'nullable', 'string', Rule::in(array_keys((array) config('locales.supported')))],
|
||||||
|
'is_enabled' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return ['to.not_in' => __('validation.different', ['attribute' => 'to', 'other' => 'from'])];
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Http/Requests/Api/V1/RepositoryRequest.php
Normal file
50
app/Http/Requests/Api/V1/RepositoryRequest.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\GitProvider;
|
||||||
|
use App\Models\Repository;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's repository form, as rules. The synced columns are not here on
|
||||||
|
* purpose: nothing a caller sends may fake a repository's numbers.
|
||||||
|
*/
|
||||||
|
class RepositoryRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$repository = $this->route('repository');
|
||||||
|
$creating = ! $repository instanceof Repository;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
$provider = $this->input('provider', $repository?->provider?->value);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'provider' => [$sometimes, Rule::enum(GitProvider::class)],
|
||||||
|
'full_name' => [
|
||||||
|
$sometimes,
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
'regex:/^[\w.-]+\/[\w.-]+$|^[\w.-]+$/',
|
||||||
|
Rule::unique('repositories', 'full_name')
|
||||||
|
->ignore($repository?->id)
|
||||||
|
->where('provider', $provider),
|
||||||
|
],
|
||||||
|
'project_id' => ['sometimes', 'nullable', 'integer', Rule::exists('projects', 'id')->whereNull('deleted_at')],
|
||||||
|
'is_primary' => ['sometimes', 'boolean'],
|
||||||
|
'show_publicly' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Http/Requests/Api/V1/TalkRequest.php
Normal file
45
app/Http/Requests/Api/V1/TalkRequest.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Talk;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's talk form, as rules. The title is the one the talk was given
|
||||||
|
* under, in `language`; only the summary is translated.
|
||||||
|
*/
|
||||||
|
class TalkRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$creating = ! $this->route('talk') instanceof Talk;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => [$sometimes, 'string', 'max:255'],
|
||||||
|
'language' => [$sometimes, 'string', Rule::in(array_keys((array) config('locales.supported')))],
|
||||||
|
'summary' => ['sometimes', 'nullable', new Translatable(max: 500)],
|
||||||
|
'event' => [$sometimes, 'string', 'max:255'],
|
||||||
|
'city' => ['sometimes', 'nullable', 'string', 'max:120'],
|
||||||
|
'held_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'video_url' => ['sometimes', 'nullable', 'url', 'max:2048'],
|
||||||
|
'slides_url' => ['sometimes', 'nullable', 'url', 'max:2048'],
|
||||||
|
'member_id' => ['sometimes', 'nullable', 'integer', Rule::exists('members', 'id')->whereNull('deleted_at')],
|
||||||
|
'project_id' => ['sometimes', 'nullable', 'integer', Rule::exists('projects', 'id')->whereNull('deleted_at')],
|
||||||
|
'is_public' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Http/Requests/Api/V1/TestimonialRequest.php
Normal file
47
app/Http/Requests/Api/V1/TestimonialRequest.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's testimonial form, as rules.
|
||||||
|
*
|
||||||
|
* `original_locale` says which language the person actually spoke in; the
|
||||||
|
* other entries of `quote` are translations of it.
|
||||||
|
*/
|
||||||
|
class TestimonialRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$creating = ! $this->route('testimonial') instanceof Testimonial;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'quote' => [$sometimes, new Translatable(max: 1000)],
|
||||||
|
'original_locale' => [$sometimes, 'string', Rule::in(array_keys((array) config('locales.supported')))],
|
||||||
|
'author_name' => [$sometimes, 'string', 'max:255'],
|
||||||
|
'author_role' => ['sometimes', 'nullable', new Translatable(max: 255)],
|
||||||
|
'avatar' => ['sometimes', 'nullable', 'string', 'max:2048'],
|
||||||
|
'client_id' => ['sometimes', 'nullable', 'integer', Rule::exists('clients', 'id')->whereNull('deleted_at')],
|
||||||
|
'project_id' => ['sometimes', 'nullable', 'integer', Rule::exists('projects', 'id')->whereNull('deleted_at')],
|
||||||
|
'service_id' => ['sometimes', 'nullable', 'integer', Rule::exists('services', 'id')->whereNull('deleted_at')],
|
||||||
|
'is_public' => ['sometimes', 'boolean'],
|
||||||
|
'position' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
'given_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Http/Resources/ApplicationResource.php
Normal file
49
app/Http/Resources/ApplicationResource.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Application;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Application
|
||||||
|
*/
|
||||||
|
class ApplicationResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* `ip_hash` stays out: it exists to spot the same sender twice, not to be
|
||||||
|
* handed around.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'kind' => $this->kind->value,
|
||||||
|
'status' => $this->status->value,
|
||||||
|
'call_id' => $this->call_id,
|
||||||
|
'subject' => $this->resource->subject(),
|
||||||
|
'name' => $this->name,
|
||||||
|
'email' => $this->email,
|
||||||
|
'organisation' => $this->organisation,
|
||||||
|
'message' => $this->message,
|
||||||
|
'hours_per_week' => $this->hours_per_week,
|
||||||
|
'links' => (object) ($this->links ?? []),
|
||||||
|
'locale' => $this->locale,
|
||||||
|
'notes' => $this->notes,
|
||||||
|
'handled_by' => $this->whenLoaded('handler', fn () => $this->handler ? [
|
||||||
|
'id' => $this->handler->id,
|
||||||
|
'name' => $this->handler->name,
|
||||||
|
] : null),
|
||||||
|
'handled_at' => $this->handled_at?->toIso8601String(),
|
||||||
|
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
'deleted_at' => $this->deleted_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Http/Resources/CallResource.php
Normal file
49
app/Http/Resources/CallResource.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Call;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Call
|
||||||
|
*/
|
||||||
|
class CallResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'locale' => $locale,
|
||||||
|
|
||||||
|
...TranslatablePayload::resolve($this->resource, $this->translatable, $locale),
|
||||||
|
|
||||||
|
'status' => $this->status->value,
|
||||||
|
'hours_per_week' => $this->hours_per_week,
|
||||||
|
'duration_weeks' => $this->duration_weeks,
|
||||||
|
'seniority' => $this->seniority,
|
||||||
|
'opens_at' => $this->opens_at?->toDateString(),
|
||||||
|
'closes_at' => $this->closes_at?->toDateString(),
|
||||||
|
'project_id' => $this->project_id,
|
||||||
|
'technology_ids' => $this->whenLoaded('technologies', fn () => $this->technologies->pluck('id')->values()),
|
||||||
|
'applications_count' => $this->whenCounted('applications'),
|
||||||
|
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
|
||||||
|
'translations' => $this->when(
|
||||||
|
in_array('translations', explode(',', (string) $request->query('with')), true),
|
||||||
|
fn () => TranslatablePayload::all($this->resource, $this->translatable),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Http/Resources/ClientResource.php
Normal file
54
app/Http/Resources/ClientResource.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Support\ImagePayload;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Client
|
||||||
|
*/
|
||||||
|
class ClientResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'locale' => $locale,
|
||||||
|
'name' => $this->name,
|
||||||
|
'slug' => $this->slug,
|
||||||
|
|
||||||
|
...TranslatablePayload::resolve($this->resource, $this->translatable, $locale),
|
||||||
|
|
||||||
|
'logo' => $this->logo,
|
||||||
|
'logo_media' => ImagePayload::make(
|
||||||
|
$this->logo,
|
||||||
|
$this->resource->relationLoaded('logoMedia') ? $this->resource->getRelation('logoMedia') : null,
|
||||||
|
$this->name,
|
||||||
|
),
|
||||||
|
'url' => $this->url,
|
||||||
|
'is_public' => (bool) $this->is_public,
|
||||||
|
'position' => $this->position,
|
||||||
|
'projects_count' => $this->whenCounted('projects'),
|
||||||
|
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
'deleted_at' => $this->deleted_at?->toIso8601String(),
|
||||||
|
|
||||||
|
'translations' => $this->when(
|
||||||
|
in_array('translations', explode(',', (string) $request->query('with')), true),
|
||||||
|
fn () => TranslatablePayload::all($this->resource, $this->translatable),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
app/Http/Resources/MissingUrlResource.php
Normal file
35
app/Http/Resources/MissingUrlResource.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\MissingUrl;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin MissingUrl
|
||||||
|
*/
|
||||||
|
class MissingUrlResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'path' => $this->path,
|
||||||
|
'locale' => $this->locale,
|
||||||
|
'referrer' => $this->referrer,
|
||||||
|
// Our own page links here: more urgent than someone else's stale link.
|
||||||
|
'internal_referrer' => $this->resource->isInternalReferrer(),
|
||||||
|
'status' => $this->status->value,
|
||||||
|
'hits' => (int) $this->hits,
|
||||||
|
'first_seen_at' => $this->first_seen_at?->toIso8601String(),
|
||||||
|
'last_seen_at' => $this->last_seen_at?->toIso8601String(),
|
||||||
|
'resolved_at' => $this->resolved_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Http/Resources/RedirectResource.php
Normal file
35
app/Http/Resources/RedirectResource.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Redirect;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Redirect
|
||||||
|
*/
|
||||||
|
class RedirectResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'from' => $this->from,
|
||||||
|
'to' => $this->to,
|
||||||
|
'status' => $this->status,
|
||||||
|
'locale' => $this->locale,
|
||||||
|
'is_enabled' => (bool) $this->is_enabled,
|
||||||
|
'automatic' => $this->resource->wasAutomatic(),
|
||||||
|
'hits' => (int) $this->hits,
|
||||||
|
'last_used_at' => $this->last_used_at?->toIso8601String(),
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Http/Resources/RepositoryResource.php
Normal file
51
app/Http/Resources/RepositoryResource.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Repository;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Repository
|
||||||
|
*/
|
||||||
|
class RepositoryResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'provider' => $this->provider->value,
|
||||||
|
'full_name' => $this->full_name,
|
||||||
|
'project_id' => $this->project_id,
|
||||||
|
'is_primary' => (bool) $this->is_primary,
|
||||||
|
'show_publicly' => (bool) $this->show_publicly,
|
||||||
|
|
||||||
|
// What the provider last reported.
|
||||||
|
'html_url' => $this->html_url,
|
||||||
|
'description' => $this->description,
|
||||||
|
'homepage' => $this->homepage,
|
||||||
|
'default_branch' => $this->default_branch,
|
||||||
|
'primary_language' => $this->primary_language,
|
||||||
|
'license' => $this->license,
|
||||||
|
'topics' => $this->topics ?? [],
|
||||||
|
'stars' => (int) $this->stars,
|
||||||
|
'forks' => (int) $this->forks,
|
||||||
|
'open_issues_count' => (int) $this->open_issues_count,
|
||||||
|
'is_archived' => (bool) $this->is_archived,
|
||||||
|
'pushed_at' => $this->pushed_at?->toIso8601String(),
|
||||||
|
|
||||||
|
'last_synced_at' => $this->last_synced_at?->toIso8601String(),
|
||||||
|
'sync_error' => $this->sync_error,
|
||||||
|
'sync_failures' => (int) $this->sync_failures,
|
||||||
|
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Resources/SettingResource.php
Normal file
27
app/Http/Resources/SettingResource.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Http/Resources/TalkResource.php
Normal file
50
app/Http/Resources/TalkResource.php
Normal 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),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Http/Resources/TestimonialResource.php
Normal file
56
app/Http/Resources/TestimonialResource.php
Normal 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),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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://.',
|
||||||
|
|||||||
@@ -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://.',
|
||||||
|
|||||||
@@ -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://.',
|
||||||
|
|||||||
1475
public/openapi.yaml
1475
public/openapi.yaml
File diff suppressed because it is too large
Load Diff
@@ -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']);
|
||||||
|
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
180
tests/Feature/Api/CallsAndInboxApiTest.php
Normal file
180
tests/Feature/Api/CallsAndInboxApiTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
tests/Feature/Api/MediaLibraryApiTest.php
Normal file
133
tests/Feature/Api/MediaLibraryApiTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
166
tests/Feature/Api/ShowcaseAdminApiTest.php
Normal file
166
tests/Feature/Api/ShowcaseAdminApiTest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
221
tests/Feature/Api/SiteAdministrationApiTest.php
Normal file
221
tests/Feature/Api/SiteAdministrationApiTest.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user