Manage clients, testimonials and talks through the API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,6 +1269,10 @@ 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)
|
||||||
POST /api/v1/media subir una imagen (fichero o URL)
|
POST /api/v1/media subir una imagen (fichero o URL)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
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),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
|||||||
@@ -112,6 +112,12 @@ tags:
|
|||||||
description: The people in the collective
|
description: The people in the collective
|
||||||
- name: Services
|
- name: Services
|
||||||
description: What the collective offers
|
description: What the collective offers
|
||||||
|
- name: Clients
|
||||||
|
description: The organisations the work was for
|
||||||
|
- name: Testimonials
|
||||||
|
description: What the people the collective worked with said
|
||||||
|
- name: Talks
|
||||||
|
description: Talks given elsewhere, by members of the collective
|
||||||
- name: Technologies
|
- name: Technologies
|
||||||
description: What the collective uses, and its place on the tech radar
|
description: What the collective uses, and its place on the tech radar
|
||||||
- name: Users
|
- name: Users
|
||||||
@@ -1645,6 +1651,352 @@ paths:
|
|||||||
data: { $ref: "#/components/schemas/Service" }
|
data: { $ref: "#/components/schemas/Service" }
|
||||||
"403": { $ref: "#/components/responses/Forbidden" }
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
|
/v1/clients:
|
||||||
|
get:
|
||||||
|
tags: [Clients]
|
||||||
|
summary: List clients
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/Lang"
|
||||||
|
- $ref: "#/components/parameters/With"
|
||||||
|
- $ref: "#/components/parameters/PerPage"
|
||||||
|
- name: is_public
|
||||||
|
in: query
|
||||||
|
schema: { type: boolean }
|
||||||
|
- name: search
|
||||||
|
in: query
|
||||||
|
description: Matches the name.
|
||||||
|
schema: { type: string }
|
||||||
|
- name: trashed
|
||||||
|
in: query
|
||||||
|
schema: { type: boolean }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Paginated clients
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ClientCollection" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
post:
|
||||||
|
tags: [Clients]
|
||||||
|
summary: Create a client
|
||||||
|
description: |
|
||||||
|
Send the `path` from `POST /v1/media` with `for=clients` as `logo`.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ClientWrite" }
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Client created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Client" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"422": { $ref: "#/components/responses/ValidationError" }
|
||||||
|
|
||||||
|
/v1/clients/{client}:
|
||||||
|
parameters:
|
||||||
|
- name: client
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: integer }
|
||||||
|
get:
|
||||||
|
tags: [Clients]
|
||||||
|
summary: Show a client
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/Lang"
|
||||||
|
- $ref: "#/components/parameters/With"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: The client
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Client" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"404": { description: Not found }
|
||||||
|
patch:
|
||||||
|
tags: [Clients]
|
||||||
|
summary: Update a client
|
||||||
|
description: Partial update. Translatable fields merge into the existing map.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ClientWrite" }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Client updated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Client" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"422": { $ref: "#/components/responses/ValidationError" }
|
||||||
|
delete:
|
||||||
|
tags: [Clients]
|
||||||
|
summary: Soft-delete a client
|
||||||
|
responses:
|
||||||
|
"204": { description: Deleted }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
|
/v1/clients/{client}/restore:
|
||||||
|
post:
|
||||||
|
tags: [Clients]
|
||||||
|
summary: Restore a soft-deleted client
|
||||||
|
parameters:
|
||||||
|
- name: client
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: integer }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Client restored
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Client" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
|
/v1/testimonials:
|
||||||
|
get:
|
||||||
|
tags: [Testimonials]
|
||||||
|
summary: List testimonials
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/Lang"
|
||||||
|
- $ref: "#/components/parameters/With"
|
||||||
|
- $ref: "#/components/parameters/PerPage"
|
||||||
|
- name: is_public
|
||||||
|
in: query
|
||||||
|
schema: { type: boolean }
|
||||||
|
- name: client_id
|
||||||
|
in: query
|
||||||
|
schema: { type: integer }
|
||||||
|
- name: project_id
|
||||||
|
in: query
|
||||||
|
schema: { type: integer }
|
||||||
|
- name: service_id
|
||||||
|
in: query
|
||||||
|
schema: { type: integer }
|
||||||
|
- name: search
|
||||||
|
in: query
|
||||||
|
description: Matches the author's name.
|
||||||
|
schema: { type: string }
|
||||||
|
- name: trashed
|
||||||
|
in: query
|
||||||
|
schema: { type: boolean }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Paginated testimonials
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/TestimonialCollection" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
post:
|
||||||
|
tags: [Testimonials]
|
||||||
|
summary: Create a testimonial
|
||||||
|
description: |
|
||||||
|
`quote` holds what the person said in `original_locale`, and any
|
||||||
|
translations of it. Send the `path` from `POST /v1/media` with
|
||||||
|
`for=testimonials` as `avatar`.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/TestimonialWrite" }
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Testimonial created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Testimonial" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"422": { $ref: "#/components/responses/ValidationError" }
|
||||||
|
|
||||||
|
/v1/testimonials/{testimonial}:
|
||||||
|
parameters:
|
||||||
|
- name: testimonial
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: integer }
|
||||||
|
get:
|
||||||
|
tags: [Testimonials]
|
||||||
|
summary: Show a testimonial
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/Lang"
|
||||||
|
- $ref: "#/components/parameters/With"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: The testimonial
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Testimonial" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"404": { description: Not found }
|
||||||
|
patch:
|
||||||
|
tags: [Testimonials]
|
||||||
|
summary: Update a testimonial
|
||||||
|
description: Partial update. Translatable fields merge into the existing map.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/TestimonialWrite" }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Testimonial updated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Testimonial" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"422": { $ref: "#/components/responses/ValidationError" }
|
||||||
|
delete:
|
||||||
|
tags: [Testimonials]
|
||||||
|
summary: Soft-delete a testimonial
|
||||||
|
responses:
|
||||||
|
"204": { description: Deleted }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
|
/v1/testimonials/{testimonial}/restore:
|
||||||
|
post:
|
||||||
|
tags: [Testimonials]
|
||||||
|
summary: Restore a soft-deleted testimonial
|
||||||
|
parameters:
|
||||||
|
- name: testimonial
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: integer }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Testimonial restored
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Testimonial" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
|
/v1/talks:
|
||||||
|
get:
|
||||||
|
tags: [Talks]
|
||||||
|
summary: List talks
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/Lang"
|
||||||
|
- $ref: "#/components/parameters/With"
|
||||||
|
- $ref: "#/components/parameters/PerPage"
|
||||||
|
- name: is_public
|
||||||
|
in: query
|
||||||
|
schema: { type: boolean }
|
||||||
|
- name: member_id
|
||||||
|
in: query
|
||||||
|
schema: { type: integer }
|
||||||
|
- name: project_id
|
||||||
|
in: query
|
||||||
|
schema: { type: integer }
|
||||||
|
- name: search
|
||||||
|
in: query
|
||||||
|
description: Matches the title or the event.
|
||||||
|
schema: { type: string }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Paginated talks
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/TalkCollection" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
post:
|
||||||
|
tags: [Talks]
|
||||||
|
summary: Create a talk
|
||||||
|
description: |
|
||||||
|
`title` is the one the talk was given under, in `language`. Only
|
||||||
|
`summary` is translated.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/TalkWrite" }
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Talk created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Talk" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"422": { $ref: "#/components/responses/ValidationError" }
|
||||||
|
|
||||||
|
/v1/talks/{talk}:
|
||||||
|
parameters:
|
||||||
|
- name: talk
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: integer }
|
||||||
|
get:
|
||||||
|
tags: [Talks]
|
||||||
|
summary: Show a talk
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/Lang"
|
||||||
|
- $ref: "#/components/parameters/With"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: The talk
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Talk" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"404": { description: Not found }
|
||||||
|
patch:
|
||||||
|
tags: [Talks]
|
||||||
|
summary: Update a talk
|
||||||
|
description: Partial update. Translatable fields merge into the existing map.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/TalkWrite" }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Talk updated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Talk" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"422": { $ref: "#/components/responses/ValidationError" }
|
||||||
|
delete:
|
||||||
|
tags: [Talks]
|
||||||
|
summary: Delete a talk
|
||||||
|
responses:
|
||||||
|
"204": { description: Deleted }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
/v1/media:
|
/v1/media:
|
||||||
post:
|
post:
|
||||||
tags: [Media]
|
tags: [Media]
|
||||||
@@ -1665,8 +2017,9 @@ paths:
|
|||||||
removed. 20 MB at most.
|
removed. 20 MB at most.
|
||||||
|
|
||||||
`for` says which kind of record the image is for — `pages` (the
|
`for` says which kind of record the image is for — `pages` (the
|
||||||
default) or `technologies` — which decides where it is stored and whose
|
default), `technologies`, `projects`, `members`, `clients` or
|
||||||
permission it takes.
|
`testimonials` — which decides where it is stored and whose permission
|
||||||
|
it takes.
|
||||||
|
|
||||||
The smaller versions and the placeholder are derived afterwards, on the
|
The smaller versions and the placeholder are derived afterwards, on the
|
||||||
queue: `processed` says whether that has happened, and the image serves
|
queue: `processed` says whether that has happened, and the image serves
|
||||||
@@ -2872,9 +3225,147 @@ components:
|
|||||||
links: { $ref: "#/components/schemas/PaginationLinks" }
|
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||||
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
||||||
|
|
||||||
|
Client:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id: { type: integer }
|
||||||
|
locale: { type: string }
|
||||||
|
name: { type: string }
|
||||||
|
slug: { type: string }
|
||||||
|
sector: { type: string, nullable: true }
|
||||||
|
logo: { type: string, nullable: true, description: The stored path. }
|
||||||
|
logo_media:
|
||||||
|
allOf: [{ $ref: "#/components/schemas/Image" }]
|
||||||
|
nullable: true
|
||||||
|
readOnly: true
|
||||||
|
url: { type: string, format: uri, nullable: true }
|
||||||
|
is_public: { type: boolean }
|
||||||
|
position: { type: integer }
|
||||||
|
projects_count: { type: integer }
|
||||||
|
created_at: { type: string, format: date-time }
|
||||||
|
updated_at: { type: string, format: date-time }
|
||||||
|
deleted_at: { type: string, format: date-time, nullable: true }
|
||||||
|
translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." }
|
||||||
|
|
||||||
|
ClientWrite:
|
||||||
|
type: object
|
||||||
|
required: [name, slug]
|
||||||
|
properties:
|
||||||
|
name: { type: string, maxLength: 255, description: A proper noun; not translated. }
|
||||||
|
slug: { type: string, maxLength: 255, pattern: "^[A-Za-z0-9_-]+$" }
|
||||||
|
sector: { $ref: "#/components/schemas/TranslatedField" }
|
||||||
|
logo: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=clients`. }
|
||||||
|
url: { type: string, format: uri, nullable: true }
|
||||||
|
is_public: { type: boolean }
|
||||||
|
position: { type: integer, minimum: 0 }
|
||||||
|
|
||||||
|
ClientCollection:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/Client" }
|
||||||
|
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||||
|
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
||||||
|
|
||||||
|
Testimonial:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id: { type: integer }
|
||||||
|
locale: { type: string }
|
||||||
|
quote: { type: string, nullable: true }
|
||||||
|
author_role: { type: string, nullable: true }
|
||||||
|
original_locale: { type: string, description: The language the person spoke in. }
|
||||||
|
author_name: { type: string }
|
||||||
|
avatar: { type: string, nullable: true, description: The stored path. }
|
||||||
|
avatar_media:
|
||||||
|
allOf: [{ $ref: "#/components/schemas/Image" }]
|
||||||
|
nullable: true
|
||||||
|
readOnly: true
|
||||||
|
client_id: { type: integer, nullable: true }
|
||||||
|
project_id: { type: integer, nullable: true }
|
||||||
|
service_id: { type: integer, nullable: true }
|
||||||
|
is_public: { type: boolean }
|
||||||
|
position: { type: integer }
|
||||||
|
given_at: { type: string, format: date, nullable: true }
|
||||||
|
created_at: { type: string, format: date-time }
|
||||||
|
updated_at: { type: string, format: date-time }
|
||||||
|
deleted_at: { type: string, format: date-time, nullable: true }
|
||||||
|
translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." }
|
||||||
|
|
||||||
|
TestimonialWrite:
|
||||||
|
type: object
|
||||||
|
required: [quote, original_locale, author_name]
|
||||||
|
properties:
|
||||||
|
quote: { $ref: "#/components/schemas/TranslatedField" }
|
||||||
|
original_locale: { type: string, description: A supported language code. }
|
||||||
|
author_name: { type: string, maxLength: 255 }
|
||||||
|
author_role: { $ref: "#/components/schemas/TranslatedField" }
|
||||||
|
avatar: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=testimonials`. }
|
||||||
|
client_id: { type: integer, nullable: true }
|
||||||
|
project_id: { type: integer, nullable: true }
|
||||||
|
service_id: { type: integer, nullable: true }
|
||||||
|
is_public: { type: boolean }
|
||||||
|
position: { type: integer, minimum: 0 }
|
||||||
|
given_at: { type: string, format: date, nullable: true }
|
||||||
|
|
||||||
|
TestimonialCollection:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/Testimonial" }
|
||||||
|
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||||
|
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
||||||
|
|
||||||
|
Talk:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id: { type: integer }
|
||||||
|
locale: { type: string }
|
||||||
|
title: { type: string }
|
||||||
|
language: { type: string }
|
||||||
|
summary: { type: string, nullable: true }
|
||||||
|
event: { type: string }
|
||||||
|
city: { type: string, nullable: true }
|
||||||
|
held_at: { type: string, format: date, nullable: true }
|
||||||
|
video_url: { type: string, format: uri, nullable: true }
|
||||||
|
slides_url: { type: string, format: uri, nullable: true }
|
||||||
|
member_id: { type: integer, nullable: true }
|
||||||
|
project_id: { type: integer, nullable: true }
|
||||||
|
is_public: { type: boolean }
|
||||||
|
created_at: { type: string, format: date-time }
|
||||||
|
updated_at: { type: string, format: date-time }
|
||||||
|
translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." }
|
||||||
|
|
||||||
|
TalkWrite:
|
||||||
|
type: object
|
||||||
|
required: [title, language, event]
|
||||||
|
properties:
|
||||||
|
title: { type: string, maxLength: 255, description: Not translated. }
|
||||||
|
language: { type: string, description: A supported language code. }
|
||||||
|
summary: { $ref: "#/components/schemas/TranslatedField" }
|
||||||
|
event: { type: string, maxLength: 255 }
|
||||||
|
city: { type: string, nullable: true, maxLength: 120 }
|
||||||
|
held_at: { type: string, format: date, nullable: true }
|
||||||
|
video_url: { type: string, format: uri, nullable: true }
|
||||||
|
slides_url: { type: string, format: uri, nullable: true }
|
||||||
|
member_id: { type: integer, nullable: true }
|
||||||
|
project_id: { type: integer, nullable: true }
|
||||||
|
is_public: { type: boolean }
|
||||||
|
|
||||||
|
TalkCollection:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/Talk" }
|
||||||
|
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||||
|
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
||||||
|
|
||||||
MediaFor:
|
MediaFor:
|
||||||
type: string
|
type: string
|
||||||
enum: [pages, technologies, projects, members]
|
enum: [pages, technologies, projects, members, clients, testimonials]
|
||||||
default: pages
|
default: pages
|
||||||
description: The kind of record the image is for.
|
description: The kind of record the image is for.
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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\CategoryController;
|
use App\Http\Controllers\Api\V1\CategoryController;
|
||||||
|
use App\Http\Controllers\Api\V1\ClientController;
|
||||||
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;
|
||||||
@@ -18,7 +19,9 @@ 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\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\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 +175,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,6 +192,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
->name("{$resource}.restore");
|
->name("{$resource}.restore");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deleted for good, like the panel does: a talk has no bin.
|
||||||
|
Route::apiResource('talks', TalkController::class);
|
||||||
|
|
||||||
// Upload only: the file is then referenced from a record by its path.
|
// Upload only: the file is then referenced from a record by its path.
|
||||||
Route::post('media', [MediaController::class, 'store'])->name('media.store');
|
Route::post('media', [MediaController::class, 'store'])->name('media.store');
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ declare(strict_types=1);
|
|||||||
namespace Tests\Feature\Api;
|
namespace Tests\Feature\Api;
|
||||||
|
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
|
use App\Models\Client;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Page;
|
use App\Models\Page;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
|
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 Database\Seeders\RolePermissionSeeder;
|
use Database\Seeders\RolePermissionSeeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@@ -33,7 +36,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 +75,9 @@ 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'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +90,9 @@ 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();
|
||||||
|
|
||||||
// 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 +111,12 @@ 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}'],
|
||||||
'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'],
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user