From b02464a7edf0be51de0bfc9d80b7227755eba4e5 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Wed, 30 Sep 2026 19:38:58 +0200 Subject: [PATCH] Manage clients, testimonials and talks through the API Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +- .../Controllers/Api/V1/ClientController.php | 90 ++++ .../Controllers/Api/V1/TalkController.php | 81 +++ .../Api/V1/TestimonialController.php | 92 ++++ app/Http/Requests/Api/V1/ClientRequest.php | 47 ++ app/Http/Requests/Api/V1/TalkRequest.php | 45 ++ .../Requests/Api/V1/TestimonialRequest.php | 47 ++ app/Http/Resources/ClientResource.php | 54 ++ app/Http/Resources/TalkResource.php | 50 ++ app/Http/Resources/TestimonialResource.php | 56 ++ config/media.php | 2 + public/openapi.yaml | 497 +++++++++++++++++- routes/api.php | 10 +- tests/Feature/Api/AdminApiContractTest.php | 17 +- tests/Feature/Api/ShowcaseAdminApiTest.php | 166 ++++++ 15 files changed, 1255 insertions(+), 7 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/ClientController.php create mode 100644 app/Http/Controllers/Api/V1/TalkController.php create mode 100644 app/Http/Controllers/Api/V1/TestimonialController.php create mode 100644 app/Http/Requests/Api/V1/ClientRequest.php create mode 100644 app/Http/Requests/Api/V1/TalkRequest.php create mode 100644 app/Http/Requests/Api/V1/TestimonialRequest.php create mode 100644 app/Http/Resources/ClientResource.php create mode 100644 app/Http/Resources/TalkResource.php create mode 100644 app/Http/Resources/TestimonialResource.php create mode 100644 tests/Feature/Api/ShowcaseAdminApiTest.php diff --git a/README.md b/README.md index ebbddfe..5e306ae 100644 --- a/README.md +++ b/README.md @@ -403,8 +403,8 @@ tabla es derivada, así que reconstruirla siempre es seguro. `POST /api/v1/media` es el campo de subida de una ficha, sin la ficha: guarda la imagen donde el panel pone las de ese tipo de ficha y contesta con el `path` que se manda como `featured_image` de una página o `logo` de una tecnología. `for` -dice para qué es (`pages`, por defecto, o `technologies`), y con ello la carpeta -y el permiso que hace falta. Existe para el [MCP](#servidor-mcp), que sin él +dice para qué es (`pages`, por defecto, `technologies`, `projects`, `members`, +`clients` o `testimonials`), y con ello la carpeta y el permiso que hace falta. Existe para el [MCP](#servidor-mcp), que sin él podía crear una página pero nunca ponerle una foto. Pasa por `Media::for()`, así que se procesa, se sirve y se poda igual que una subida desde el panel. @@ -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/technologies CRUD completo; filtros: category, radar_ring (none = fuera del radar), search + /api/v1/clients CRUD + restore; filtros: is_public, search + /api/v1/testimonials CRUD + restore; filtros: is_public, client_id, + project_id, service_id, search + /api/v1/talks CRUD (sin papelera, como en el panel) POST /api/v1/media subir una imagen (fichero o URL) ``` diff --git a/app/Http/Controllers/Api/V1/ClientController.php b/app/Http/Controllers/Api/V1/ClientController.php new file mode 100644 index 0000000..f43ab4f --- /dev/null +++ b/app/Http/Controllers/Api/V1/ClientController.php @@ -0,0 +1,90 @@ +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'); + } +} diff --git a/app/Http/Controllers/Api/V1/TalkController.php b/app/Http/Controllers/Api/V1/TalkController.php new file mode 100644 index 0000000..8c25a78 --- /dev/null +++ b/app/Http/Controllers/Api/V1/TalkController.php @@ -0,0 +1,81 @@ +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; + } +} diff --git a/app/Http/Controllers/Api/V1/TestimonialController.php b/app/Http/Controllers/Api/V1/TestimonialController.php new file mode 100644 index 0000000..817a3b0 --- /dev/null +++ b/app/Http/Controllers/Api/V1/TestimonialController.php @@ -0,0 +1,92 @@ +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); + } +} diff --git a/app/Http/Requests/Api/V1/ClientRequest.php b/app/Http/Requests/Api/V1/ClientRequest.php new file mode 100644 index 0000000..2351509 --- /dev/null +++ b/app/Http/Requests/Api/V1/ClientRequest.php @@ -0,0 +1,47 @@ + + */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Api/V1/TalkRequest.php b/app/Http/Requests/Api/V1/TalkRequest.php new file mode 100644 index 0000000..7cbd95e --- /dev/null +++ b/app/Http/Requests/Api/V1/TalkRequest.php @@ -0,0 +1,45 @@ + + */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Api/V1/TestimonialRequest.php b/app/Http/Requests/Api/V1/TestimonialRequest.php new file mode 100644 index 0000000..dae25e7 --- /dev/null +++ b/app/Http/Requests/Api/V1/TestimonialRequest.php @@ -0,0 +1,47 @@ + + */ + 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'], + ]; + } +} diff --git a/app/Http/Resources/ClientResource.php b/app/Http/Resources/ClientResource.php new file mode 100644 index 0000000..ea80e25 --- /dev/null +++ b/app/Http/Resources/ClientResource.php @@ -0,0 +1,54 @@ + + */ + 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), + ), + ]; + } +} diff --git a/app/Http/Resources/TalkResource.php b/app/Http/Resources/TalkResource.php new file mode 100644 index 0000000..0f03d74 --- /dev/null +++ b/app/Http/Resources/TalkResource.php @@ -0,0 +1,50 @@ + + */ + 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), + ), + ]; + } +} diff --git a/app/Http/Resources/TestimonialResource.php b/app/Http/Resources/TestimonialResource.php new file mode 100644 index 0000000..1fe0e91 --- /dev/null +++ b/app/Http/Resources/TestimonialResource.php @@ -0,0 +1,56 @@ + + */ + 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), + ), + ]; + } +} diff --git a/config/media.php b/config/media.php index 4f05d2a..15ddec0 100644 --- a/config/media.php +++ b/config/media.php @@ -107,6 +107,8 @@ return [ 'technologies' => 'technologies', 'projects' => 'projects', 'members' => 'members', + 'clients' => 'clients', + 'testimonials' => 'testimonials', ], // Fetching from a URL: how long to wait, in seconds. The request that diff --git a/public/openapi.yaml b/public/openapi.yaml index ed672f6..358413d 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -112,6 +112,12 @@ tags: description: The people in the collective - name: Services 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 description: What the collective uses, and its place on the tech radar - name: Users @@ -1645,6 +1651,352 @@ paths: data: { $ref: "#/components/schemas/Service" } "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: post: tags: [Media] @@ -1665,8 +2017,9 @@ paths: removed. 20 MB at most. `for` says which kind of record the image is for — `pages` (the - default) or `technologies` — which decides where it is stored and whose - permission it takes. + default), `technologies`, `projects`, `members`, `clients` or + `testimonials` — which decides where it is stored and whose permission + it takes. The smaller versions and the placeholder are derived afterwards, on the queue: `processed` says whether that has happened, and the image serves @@ -2872,9 +3225,147 @@ components: links: { $ref: "#/components/schemas/PaginationLinks" } 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: type: string - enum: [pages, technologies, projects, members] + enum: [pages, technologies, projects, members, clients, testimonials] default: pages description: The kind of record the image is for. diff --git a/routes/api.php b/routes/api.php index c219abd..52df4c9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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\TelemetryController; 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\MediaController; 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\RoleController; 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\TestimonialController; use App\Http\Controllers\Api\V1\TokenController; use App\Http\Controllers\Api\V1\UserController; use Illuminate\Support\Facades\Route; @@ -172,12 +175,14 @@ Route::prefix('v1')->name('api.v1.')->group(function () { 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. foreach ([ 'projects' => ProjectController::class, 'members' => MemberController::class, 'services' => ServiceController::class, + 'clients' => ClientController::class, + 'testimonials' => TestimonialController::class, ] as $resource => $controller) { $parameter = str($resource)->singular()->toString(); @@ -187,6 +192,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () { ->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. Route::post('media', [MediaController::class, 'store'])->name('media.store'); diff --git a/tests/Feature/Api/AdminApiContractTest.php b/tests/Feature/Api/AdminApiContractTest.php index 75e6863..7a8f20a 100644 --- a/tests/Feature/Api/AdminApiContractTest.php +++ b/tests/Feature/Api/AdminApiContractTest.php @@ -5,11 +5,14 @@ declare(strict_types=1); namespace Tests\Feature\Api; use App\Models\Category; +use App\Models\Client; use App\Models\Member; use App\Models\Page; use App\Models\Project; use App\Models\Service; +use App\Models\Talk; use App\Models\Technology; +use App\Models\Testimonial; use App\Models\User; use Database\Seeders\RolePermissionSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -33,7 +36,7 @@ use Tests\VerifiesOpenApiContract; * test exercises behaviour, not shape. * * 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 * rule throughout: everything documented and not `nullable` must be present. */ @@ -72,6 +75,9 @@ class AdminApiContractTest extends TestCase 'projects' => ['path' => '/v1/projects'], 'members' => ['path' => '/v1/members'], '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(); $member = Member::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 // 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}'], 'services' => ['/v1/services', '/v1/services'], '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'], 'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'], 'me' => ['/v1/me', '/v1/me'], diff --git a/tests/Feature/Api/ShowcaseAdminApiTest.php b/tests/Feature/Api/ShowcaseAdminApiTest.php new file mode 100644 index 0000000..b97138c --- /dev/null +++ b/tests/Feature/Api/ShowcaseAdminApiTest.php @@ -0,0 +1,166 @@ +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(); + } +}