Manage clients, testimonials and talks through the API

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 19:38:58 +02:00
parent 96747abdc3
commit b02464a7ed
15 changed files with 1255 additions and 7 deletions

View File

@@ -403,8 +403,8 @@ tabla es derivada, así que reconstruirla siempre es seguro.
`POST /api/v1/media` es el campo de subida de una ficha, sin la ficha: guarda la `POST /api/v1/media` es el campo de subida de una ficha, sin la ficha: guarda la
imagen donde el panel pone las de ese tipo de ficha y contesta con el `path` que imagen donde el panel pone las de ese tipo de ficha y contesta con el `path` que
se manda como `featured_image` de una página o `logo` de una tecnología. `for` se manda como `featured_image` de una página o `logo` de una tecnología. `for`
dice para qué es (`pages`, por defecto, o `technologies`), y con ello la carpeta dice para qué es (`pages`, por defecto, `technologies`, `projects`, `members`,
y el permiso que hace falta. Existe para el [MCP](#servidor-mcp), que sin él `clients` o `testimonials`), y con ello la carpeta y el permiso que hace falta. Existe para el [MCP](#servidor-mcp), que sin él
podía crear una página pero nunca ponerle una foto. Pasa por `Media::for()`, podía crear una página pero nunca ponerle una foto. Pasa por `Media::for()`,
así que se procesa, se sirve y se poda igual que una subida desde el panel. así que se procesa, se sirve y se poda igual que una subida desde el panel.
@@ -1269,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)
``` ```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Talk;
use App\Support\TranslatablePayload;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Talk
*/
class TalkResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
return [
'id' => $this->id,
'locale' => $locale,
'title' => $this->title,
'language' => $this->language,
...TranslatablePayload::resolve($this->resource, $this->translatable, $locale),
'event' => $this->event,
'city' => $this->city,
'held_at' => $this->held_at?->toDateString(),
'video_url' => $this->video_url,
'slides_url' => $this->slides_url,
'member_id' => $this->member_id,
'project_id' => $this->project_id,
'is_public' => (bool) $this->is_public,
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
'translations' => $this->when(
in_array('translations', explode(',', (string) $request->query('with')), true),
fn () => TranslatablePayload::all($this->resource, $this->translatable),
),
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Testimonial;
use App\Support\ImagePayload;
use App\Support\TranslatablePayload;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Testimonial
*/
class TestimonialResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
return [
'id' => $this->id,
'locale' => $locale,
...TranslatablePayload::resolve($this->resource, $this->translatable, $locale),
'original_locale' => $this->original_locale,
'author_name' => $this->author_name,
'avatar' => $this->avatar,
'avatar_media' => ImagePayload::make(
$this->avatar,
$this->resource->relationLoaded('avatarMedia') ? $this->resource->getRelation('avatarMedia') : null,
$this->author_name,
),
'client_id' => $this->client_id,
'project_id' => $this->project_id,
'service_id' => $this->service_id,
'is_public' => (bool) $this->is_public,
'position' => $this->position,
'given_at' => $this->given_at?->toDateString(),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
'deleted_at' => $this->deleted_at?->toIso8601String(),
'translations' => $this->when(
in_array('translations', explode(',', (string) $request->query('with')), true),
fn () => TranslatablePayload::all($this->resource, $this->translatable),
),
];
}
}

View File

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

View File

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

View File

@@ -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');

View File

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

View File

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