Manage projects, members and services through the API
/api/v1/projects, /members and /services: create, list, show, update, delete and restore, with the panel's own rules — so the MCP server can load the collective's work, people and offer without anyone retyping them into forms. The MCP server picks the tools up from openapi.yaml. - Relations travel as ids and are replaced whole when sent, left alone when not: a project's technology_ids, service_ids and members with each one's role; a member's technology_ids; a service's technology_ids and project_ids. Record and relations are saved in one transaction. - Publishing stays a permission of its own. Without projects.publish or services.publish, published_at, unpublish_at and is_published are refused — the fields the panel disables for the same people. - MemberPolicy still lets someone edit their own profile. - Uploads accept `for: projects` and `for: members`, landing where the panel's own upload fields put them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
10
README.md
10
README.md
@@ -1263,11 +1263,21 @@ POST /api/v1/pages/{page}/restore
|
|||||||
GET /api/v1/users PATCH para roles, is_active, locale
|
GET /api/v1/users PATCH para roles, is_active, locale
|
||||||
GET /api/v1/roles solo lectura
|
GET /api/v1/roles solo lectura
|
||||||
GET /api/v1/roles/catalogue catálogo de permisos agrupado
|
GET /api/v1/roles/catalogue catálogo de permisos agrupado
|
||||||
|
/api/v1/projects CRUD + restore; relaciones por ids:
|
||||||
|
technology_ids, service_ids, members[{id, role}]
|
||||||
|
/api/v1/members CRUD + restore; links (etiqueta → URL), technology_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
|
||||||
POST /api/v1/media subir una imagen (fichero o URL)
|
POST /api/v1/media subir una imagen (fichero o URL)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Publicar sigue siendo un permiso aparte, como en el panel (donde esos campos
|
||||||
|
salen desactivados): sin `projects.publish` o `services.publish`, la API rechaza
|
||||||
|
`published_at`, `unpublish_at` e `is_published`, así que quien puede escribir
|
||||||
|
algo no puede sacarlo a la web solo. Las relaciones enviadas sustituyen a las
|
||||||
|
que había, y las no enviadas no se tocan; todo se guarda en una transacción.
|
||||||
|
|
||||||
`GET /v1/me` devuelve el array `permissions`: úsalo para construir la UI del
|
`GET /v1/me` devuelve el array `permissions`: úsalo para construir la UI del
|
||||||
cliente en vez de comprobar nombres de rol.
|
cliente en vez de comprobar nombres de rol.
|
||||||
|
|
||||||
|
|||||||
103
app/Http/Controllers/Api/V1/MemberController.php
Normal file
103
app/Http/Controllers/Api/V1/MemberController.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\MemberRequest;
|
||||||
|
use App\Http\Resources\MemberResource;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The people in the collective, as the panel edits them. MemberPolicy lets
|
||||||
|
* someone edit their own profile without `members.update`, as in the panel.
|
||||||
|
*/
|
||||||
|
class MemberController extends Controller
|
||||||
|
{
|
||||||
|
protected const WITH = ['avatarMedia', 'technologies', 'projects'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: search (name), is_public, trashed.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Member::class);
|
||||||
|
|
||||||
|
$members = Member::query()
|
||||||
|
->with(self::WITH)
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where('name', 'like', '%'.$request->string('search').'%'))
|
||||||
|
->when($request->has('is_public'), fn ($q) => $q->where('is_public', $request->boolean('is_public')))
|
||||||
|
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
|
||||||
|
->orderBy('position')
|
||||||
|
->orderBy('name')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return MemberResource::collection($members);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(MemberRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Member::class);
|
||||||
|
|
||||||
|
return MemberResource::make($this->save(new Member, $request))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Member $member): MemberResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $member);
|
||||||
|
|
||||||
|
return MemberResource::make($member->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(MemberRequest $request, Member $member): MemberResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $member);
|
||||||
|
|
||||||
|
return MemberResource::make($this->save($member, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Member $member): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $member);
|
||||||
|
|
||||||
|
$member->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(Member $member): MemberResource
|
||||||
|
{
|
||||||
|
$this->authorize('restore', $member);
|
||||||
|
|
||||||
|
$member->restore();
|
||||||
|
|
||||||
|
return MemberResource::make($member->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function save(Member $member, MemberRequest $request): Member
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($member, $data): void {
|
||||||
|
$member->fill(TranslatablePayload::merge(
|
||||||
|
$member,
|
||||||
|
collect($data)->except('technology_ids')->all(),
|
||||||
|
$member->translatable,
|
||||||
|
));
|
||||||
|
$member->save();
|
||||||
|
|
||||||
|
if (array_key_exists('technology_ids', $data)) {
|
||||||
|
$member->technologies()->sync($data['technology_ids']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $member->load(self::WITH);
|
||||||
|
}
|
||||||
|
}
|
||||||
122
app/Http/Controllers/Api/V1/ProjectController.php
Normal file
122
app/Http/Controllers/Api/V1/ProjectController.php
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\ProjectRequest;
|
||||||
|
use App\Http\Resources\ProjectResource;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projects, as the panel edits them — so the MCP server can load the
|
||||||
|
* collective's work without anyone copying it into a form by hand.
|
||||||
|
*/
|
||||||
|
class ProjectController extends Controller
|
||||||
|
{
|
||||||
|
/** Relations every answer carries, so a client can PATCH back what it read. */
|
||||||
|
protected const WITH = ['coverMedia', 'technologies', 'services', 'members'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: status, search (title in the active language), trashed.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Project::class);
|
||||||
|
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
|
||||||
|
$projects = Project::query()
|
||||||
|
->with(self::WITH)
|
||||||
|
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where("title->{$locale}", 'like', '%'.$request->string('search').'%'))
|
||||||
|
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
|
||||||
|
->orderBy('position')
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return ProjectResource::collection($projects);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(ProjectRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Project::class);
|
||||||
|
|
||||||
|
$project = $this->save(new Project, $request);
|
||||||
|
|
||||||
|
return ProjectResource::make($project)->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Project $project): ProjectResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $project);
|
||||||
|
|
||||||
|
return ProjectResource::make($project->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(ProjectRequest $request, Project $project): ProjectResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $project);
|
||||||
|
|
||||||
|
return ProjectResource::make($this->save($project, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Project $project): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $project);
|
||||||
|
|
||||||
|
$project->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(Project $project): ProjectResource
|
||||||
|
{
|
||||||
|
$this->authorize('restore', $project);
|
||||||
|
|
||||||
|
$project->restore();
|
||||||
|
|
||||||
|
return ProjectResource::make($project->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The record and its relations in one transaction: a project that saved
|
||||||
|
* but lost its team halfway would be worse than one that did not save.
|
||||||
|
*/
|
||||||
|
protected function save(Project $project, ProjectRequest $request): Project
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($project, $data): void {
|
||||||
|
$project->fill(TranslatablePayload::merge(
|
||||||
|
$project,
|
||||||
|
collect($data)->except(['technology_ids', 'service_ids', 'members'])->all(),
|
||||||
|
$project->translatable,
|
||||||
|
));
|
||||||
|
$project->save();
|
||||||
|
|
||||||
|
if (array_key_exists('technology_ids', $data)) {
|
||||||
|
$project->technologies()->sync($data['technology_ids']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('service_ids', $data)) {
|
||||||
|
$project->services()->sync($data['service_ids']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('members', $data)) {
|
||||||
|
$project->members()->sync(collect($data['members'])->mapWithKeys(
|
||||||
|
fn (array $member) => [$member['id'] => ['role' => $member['role'] ?? null]],
|
||||||
|
)->all());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $project->load(self::WITH);
|
||||||
|
}
|
||||||
|
}
|
||||||
107
app/Http/Controllers/Api/V1/ServiceController.php
Normal file
107
app/Http/Controllers/Api/V1/ServiceController.php
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\ServiceRequest;
|
||||||
|
use App\Http\Resources\ServiceResource;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the collective offers, as the panel edits it.
|
||||||
|
*/
|
||||||
|
class ServiceController extends Controller
|
||||||
|
{
|
||||||
|
protected const WITH = ['technologies', 'projects'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: is_published, search (title in the active language), trashed.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Service::class);
|
||||||
|
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
|
||||||
|
$services = Service::query()
|
||||||
|
->with(self::WITH)
|
||||||
|
->when($request->has('is_published'), fn ($q) => $q->where('is_published', $request->boolean('is_published')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where("title->{$locale}", 'like', '%'.$request->string('search').'%'))
|
||||||
|
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
|
||||||
|
->orderBy('position')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return ServiceResource::collection($services);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(ServiceRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', Service::class);
|
||||||
|
|
||||||
|
return ServiceResource::make($this->save(new Service, $request))->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Service $service): ServiceResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $service);
|
||||||
|
|
||||||
|
return ServiceResource::make($service->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(ServiceRequest $request, Service $service): ServiceResource
|
||||||
|
{
|
||||||
|
$this->authorize('update', $service);
|
||||||
|
|
||||||
|
return ServiceResource::make($this->save($service, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Service $service): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $service);
|
||||||
|
|
||||||
|
$service->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(Service $service): ServiceResource
|
||||||
|
{
|
||||||
|
$this->authorize('restore', $service);
|
||||||
|
|
||||||
|
$service->restore();
|
||||||
|
|
||||||
|
return ServiceResource::make($service->load(self::WITH));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function save(Service $service, ServiceRequest $request): Service
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($service, $data): void {
|
||||||
|
$service->fill(TranslatablePayload::merge(
|
||||||
|
$service,
|
||||||
|
collect($data)->except(['technology_ids', 'project_ids'])->all(),
|
||||||
|
$service->translatable,
|
||||||
|
));
|
||||||
|
$service->save();
|
||||||
|
|
||||||
|
if (array_key_exists('technology_ids', $data)) {
|
||||||
|
$service->technologies()->sync($data['technology_ids']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('project_ids', $data)) {
|
||||||
|
$service->projects()->sync($data['project_ids']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $service->load(self::WITH);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Http/Requests/Api/V1/Concerns/GuardsPublication.php
Normal file
33
app/Http/Requests/Api/V1/Concerns/GuardsPublication.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishing is a permission of its own, as in the panel — where these fields
|
||||||
|
* are disabled for whoever lacks it. Here they are refused, so someone who may
|
||||||
|
* write a record still cannot put it on the site, or take it off, alone.
|
||||||
|
*/
|
||||||
|
trait GuardsPublication
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $fields
|
||||||
|
*/
|
||||||
|
protected function guardPublication(Validator $validator, string $permission, array $fields): void
|
||||||
|
{
|
||||||
|
$validator->after(function (Validator $validator) use ($permission, $fields): void {
|
||||||
|
if ($this->user()?->can($permission)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
if ($this->has($field)) {
|
||||||
|
$validator->errors()->add($field, __('rules.publication.forbidden'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/Http/Requests/Api/V1/MemberRequest.php
Normal file
66
app/Http/Requests/Api/V1/MemberRequest.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's member form, as rules. A name is a person's, the same in every
|
||||||
|
* language, and so is the slug built from it; only the headline and the bio
|
||||||
|
* are translatable.
|
||||||
|
*
|
||||||
|
* `links` is a label → URL map (`{"github": "https://…"}`). `technology_ids`
|
||||||
|
* is replaced whole when sent. Which projects someone is on is set from the
|
||||||
|
* project, where the role belongs.
|
||||||
|
*/
|
||||||
|
class MemberRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$member = $this->route('member');
|
||||||
|
$creating = ! $member instanceof Member;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => [$sometimes, 'string', 'max:255'],
|
||||||
|
'slug' => [
|
||||||
|
$sometimes,
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
'alpha_dash:ascii',
|
||||||
|
Rule::unique('members', 'slug')->ignore($member?->id),
|
||||||
|
],
|
||||||
|
'headline' => ['sometimes', 'nullable', new Translatable(max: 255)],
|
||||||
|
'bio' => ['sometimes', 'nullable', new Translatable],
|
||||||
|
|
||||||
|
'avatar' => ['sometimes', 'nullable', 'string', 'max:2048'],
|
||||||
|
// Never shown on the site; the panel keeps it for the collective.
|
||||||
|
'email' => ['sometimes', 'nullable', 'email', 'max:255'],
|
||||||
|
'github_username' => ['sometimes', 'nullable', 'string', 'max:120'],
|
||||||
|
'links' => ['sometimes', 'nullable', 'array', 'max:10'],
|
||||||
|
'links.*' => ['nullable', 'url', 'max:2048'],
|
||||||
|
|
||||||
|
'is_public' => ['sometimes', 'boolean'],
|
||||||
|
'is_founder' => ['sometimes', 'boolean'],
|
||||||
|
'joined_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'left_at' => ['sometimes', 'nullable', 'date', 'after_or_equal:joined_at'],
|
||||||
|
'position' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
|
||||||
|
'technology_ids' => ['sometimes', 'array'],
|
||||||
|
'technology_ids.*' => ['integer', 'distinct', Rule::exists('technologies', 'id')],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
84
app/Http/Requests/Api/V1/ProjectRequest.php
Normal file
84
app/Http/Requests/Api/V1/ProjectRequest.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\ProjectStatus;
|
||||||
|
use App\Http\Requests\Api\V1\Concerns\GuardsPublication;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use App\Rules\UniqueTranslatedSlug;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's project form, as rules. On update every field is optional
|
||||||
|
* (PATCH semantics) but keeps the same shape.
|
||||||
|
*
|
||||||
|
* Relations travel as ids and are replaced whole when sent: `technology_ids`,
|
||||||
|
* `service_ids`, and `members` with each one's role on the project.
|
||||||
|
*/
|
||||||
|
class ProjectRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use GuardsPublication;
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$project = $this->route('project');
|
||||||
|
$creating = ! $project instanceof Project;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => [$sometimes, new Translatable(requireDefaultLocale: $creating, max: 255)],
|
||||||
|
'slug' => [
|
||||||
|
$sometimes,
|
||||||
|
new Translatable(requireDefaultLocale: $creating, max: 255),
|
||||||
|
new UniqueTranslatedSlug(Project::class, $project?->id),
|
||||||
|
],
|
||||||
|
'tagline' => ['sometimes', 'nullable', new Translatable(max: 300)],
|
||||||
|
'description' => ['sometimes', 'nullable', new Translatable],
|
||||||
|
'decisions' => ['sometimes', 'nullable', new Translatable],
|
||||||
|
'postmortem' => ['sometimes', 'nullable', new Translatable],
|
||||||
|
'meta_title' => ['sometimes', 'nullable', new Translatable(max: 60)],
|
||||||
|
'meta_description' => ['sometimes', 'nullable', new Translatable(max: 160)],
|
||||||
|
|
||||||
|
'status' => ['sometimes', Rule::enum(ProjectStatus::class)],
|
||||||
|
'is_featured' => ['sometimes', 'boolean'],
|
||||||
|
'needs_help' => ['sometimes', 'boolean'],
|
||||||
|
'demo_url' => ['sometimes', 'nullable', 'url', 'max:2048'],
|
||||||
|
'cover_image' => ['sometimes', 'nullable', 'string', 'max:2048'],
|
||||||
|
'cover_image_alt' => ['sometimes', 'nullable', new Translatable(max: 255)],
|
||||||
|
'started_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'ended_at' => ['sometimes', 'nullable', 'date', 'after_or_equal:started_at'],
|
||||||
|
'published_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'unpublish_at' => ['sometimes', 'nullable', 'date', 'after:published_at'],
|
||||||
|
'position' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
|
||||||
|
'client_id' => ['sometimes', 'nullable', 'integer', Rule::exists('clients', 'id')->whereNull('deleted_at')],
|
||||||
|
'lead_member_id' => ['sometimes', 'nullable', 'integer', Rule::exists('members', 'id')->whereNull('deleted_at')],
|
||||||
|
|
||||||
|
'technology_ids' => ['sometimes', 'array'],
|
||||||
|
'technology_ids.*' => ['integer', 'distinct', Rule::exists('technologies', 'id')],
|
||||||
|
'service_ids' => ['sometimes', 'array'],
|
||||||
|
'service_ids.*' => ['integer', 'distinct', Rule::exists('services', 'id')->whereNull('deleted_at')],
|
||||||
|
'members' => ['sometimes', 'array'],
|
||||||
|
'members.*.id' => ['required', 'integer', 'distinct', Rule::exists('members', 'id')->whereNull('deleted_at')],
|
||||||
|
'members.*.role' => ['nullable', 'string', 'max:120'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withValidator(Validator $validator): void
|
||||||
|
{
|
||||||
|
$this->guardPublication($validator, 'projects.publish', ['published_at', 'unpublish_at']);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Http/Requests/Api/V1/ServiceRequest.php
Normal file
68
app/Http/Requests/Api/V1/ServiceRequest.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Requests\Api\V1\Concerns\GuardsPublication;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Rules\Translatable;
|
||||||
|
use App\Rules\UniqueTranslatedSlug;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's service form, as rules. `technology_ids` and `project_ids` are
|
||||||
|
* replaced whole when sent.
|
||||||
|
*/
|
||||||
|
class ServiceRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use GuardsPublication;
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$service = $this->route('service');
|
||||||
|
$creating = ! $service instanceof Service;
|
||||||
|
$sometimes = $creating ? 'required' : 'sometimes';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => [$sometimes, new Translatable(requireDefaultLocale: $creating, max: 255)],
|
||||||
|
'slug' => [
|
||||||
|
$sometimes,
|
||||||
|
new Translatable(requireDefaultLocale: $creating, max: 255),
|
||||||
|
new UniqueTranslatedSlug(Service::class, $service?->id),
|
||||||
|
],
|
||||||
|
'tagline' => ['sometimes', 'nullable', new Translatable(max: 255)],
|
||||||
|
'description' => ['sometimes', 'nullable', new Translatable],
|
||||||
|
'meta_title' => ['sometimes', 'nullable', new Translatable(max: 60)],
|
||||||
|
'meta_description' => ['sometimes', 'nullable', new Translatable(max: 160)],
|
||||||
|
|
||||||
|
// A name from the icon set the frontend resolves, e.g. `support`.
|
||||||
|
'icon' => ['sometimes', 'nullable', 'string', 'max:120'],
|
||||||
|
'is_featured' => ['sometimes', 'boolean'],
|
||||||
|
'position' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
'is_published' => ['sometimes', 'boolean'],
|
||||||
|
'published_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'unpublish_at' => ['sometimes', 'nullable', 'date', 'after:published_at'],
|
||||||
|
|
||||||
|
'technology_ids' => ['sometimes', 'array'],
|
||||||
|
'technology_ids.*' => ['integer', 'distinct', Rule::exists('technologies', 'id')],
|
||||||
|
'project_ids' => ['sometimes', 'array'],
|
||||||
|
'project_ids.*' => ['integer', 'distinct', Rule::exists('projects', 'id')->whereNull('deleted_at')],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withValidator(Validator $validator): void
|
||||||
|
{
|
||||||
|
$this->guardPublication($validator, 'services.publish', ['is_published', 'published_at', 'unpublish_at']);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/Http/Resources/MemberResource.php
Normal file
65
app/Http/Resources/MemberResource.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Support\ImagePayload;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Member
|
||||||
|
*/
|
||||||
|
class MemberResource 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),
|
||||||
|
|
||||||
|
'avatar' => $this->avatar,
|
||||||
|
'avatar_media' => ImagePayload::make(
|
||||||
|
$this->avatar,
|
||||||
|
$this->resource->relationLoaded('avatarMedia') ? $this->resource->getRelation('avatarMedia') : null,
|
||||||
|
$this->name,
|
||||||
|
),
|
||||||
|
'email' => $this->email,
|
||||||
|
'github_username' => $this->github_username,
|
||||||
|
'links' => (object) ($this->links ?? []),
|
||||||
|
'is_public' => (bool) $this->is_public,
|
||||||
|
'is_founder' => (bool) $this->is_founder,
|
||||||
|
'joined_at' => $this->joined_at?->toDateString(),
|
||||||
|
'left_at' => $this->left_at?->toDateString(),
|
||||||
|
'position' => $this->position,
|
||||||
|
|
||||||
|
'technology_ids' => $this->whenLoaded('technologies', fn () => $this->technologies->pluck('id')->values()),
|
||||||
|
'projects' => $this->whenLoaded('projects', fn () => $this->projects->map(fn ($project) => [
|
||||||
|
'id' => $project->id,
|
||||||
|
'title' => $project->getTranslation('title', $locale),
|
||||||
|
'role' => $project->pivot->role,
|
||||||
|
])->values()),
|
||||||
|
|
||||||
|
'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),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/Http/Resources/ProjectResource.php
Normal file
70
app/Http/Resources/ProjectResource.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Support\ImagePayload;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Project
|
||||||
|
*/
|
||||||
|
class ProjectResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
|
||||||
|
$resolved = TranslatablePayload::resolve($this->resource, $this->translatable, $locale);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'locale' => $locale,
|
||||||
|
|
||||||
|
...$resolved,
|
||||||
|
|
||||||
|
'status' => $this->status->value,
|
||||||
|
'is_featured' => (bool) $this->is_featured,
|
||||||
|
'needs_help' => (bool) $this->needs_help,
|
||||||
|
'demo_url' => $this->demo_url,
|
||||||
|
// The stored path, which is what a client PATCHes back; what was
|
||||||
|
// derived from it travels alongside, as on a page.
|
||||||
|
'cover_image' => $this->cover_image,
|
||||||
|
'cover_image_media' => ImagePayload::make(
|
||||||
|
$this->cover_image,
|
||||||
|
$this->resource->relationLoaded('coverMedia') ? $this->resource->getRelation('coverMedia') : null,
|
||||||
|
$resolved['cover_image_alt'] ?? null,
|
||||||
|
),
|
||||||
|
'started_at' => $this->started_at?->toDateString(),
|
||||||
|
'ended_at' => $this->ended_at?->toDateString(),
|
||||||
|
'published_at' => $this->published_at?->toIso8601String(),
|
||||||
|
'unpublish_at' => $this->unpublish_at?->toIso8601String(),
|
||||||
|
'position' => $this->position,
|
||||||
|
|
||||||
|
'client_id' => $this->client_id,
|
||||||
|
'lead_member_id' => $this->lead_member_id,
|
||||||
|
'technology_ids' => $this->whenLoaded('technologies', fn () => $this->technologies->pluck('id')->values()),
|
||||||
|
'service_ids' => $this->whenLoaded('services', fn () => $this->services->pluck('id')->values()),
|
||||||
|
'members' => $this->whenLoaded('members', fn () => $this->members->map(fn ($member) => [
|
||||||
|
'id' => $member->id,
|
||||||
|
'name' => $member->name,
|
||||||
|
'role' => $member->pivot->role,
|
||||||
|
])->values()),
|
||||||
|
|
||||||
|
'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/ServiceResource.php
Normal file
50
app/Http/Resources/ServiceResource.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Support\TranslatablePayload;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin Service
|
||||||
|
*/
|
||||||
|
class ServiceResource 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),
|
||||||
|
|
||||||
|
'icon' => $this->icon,
|
||||||
|
'is_featured' => (bool) $this->is_featured,
|
||||||
|
'is_published' => (bool) $this->is_published,
|
||||||
|
'published_at' => $this->published_at?->toIso8601String(),
|
||||||
|
'unpublish_at' => $this->unpublish_at?->toIso8601String(),
|
||||||
|
'position' => $this->position,
|
||||||
|
|
||||||
|
'technology_ids' => $this->whenLoaded('technologies', fn () => $this->technologies->pluck('id')->values()),
|
||||||
|
'project_ids' => $this->whenLoaded('projects', fn () => $this->projects->pluck('id')->values()),
|
||||||
|
|
||||||
|
'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),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,6 +105,8 @@ return [
|
|||||||
'for' => [
|
'for' => [
|
||||||
'pages' => 'pages',
|
'pages' => 'pages',
|
||||||
'technologies' => 'technologies',
|
'technologies' => 'technologies',
|
||||||
|
'projects' => 'projects',
|
||||||
|
'members' => 'members',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Fetching from a URL: how long to wait, in seconds. The request that
|
// Fetching from a URL: how long to wait, in seconds. The request that
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ return [
|
|||||||
'cannot_publish' => 'No tens permís per publicar pàgines.',
|
'cannot_publish' => 'No tens permís per publicar pàgines.',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'publication' => [
|
||||||
|
'forbidden' => 'No tens permís per publicar ni despublicar això.',
|
||||||
|
],
|
||||||
|
|
||||||
'media' => [
|
'media' => [
|
||||||
'not_an_image' => 'El fitxer no és una imatge JPEG, PNG, WebP, GIF, AVIF o SVG.',
|
'not_an_image' => 'El fitxer no és una imatge JPEG, PNG, WebP, GIF, AVIF o SVG.',
|
||||||
'remote' => [
|
'remote' => [
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ return [
|
|||||||
'cannot_publish' => 'You do not have permission to publish pages.',
|
'cannot_publish' => 'You do not have permission to publish pages.',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'publication' => [
|
||||||
|
'forbidden' => 'You do not have permission to publish or unpublish this.',
|
||||||
|
],
|
||||||
|
|
||||||
'media' => [
|
'media' => [
|
||||||
'not_an_image' => 'The file is not a JPEG, PNG, WebP, GIF, AVIF or SVG image.',
|
'not_an_image' => 'The file is not a JPEG, PNG, WebP, GIF, AVIF or SVG image.',
|
||||||
'remote' => [
|
'remote' => [
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ return [
|
|||||||
'cannot_publish' => 'No tienes permiso para publicar páginas.',
|
'cannot_publish' => 'No tienes permiso para publicar páginas.',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'publication' => [
|
||||||
|
'forbidden' => 'No tienes permiso para publicar ni despublicar esto.',
|
||||||
|
],
|
||||||
|
|
||||||
'media' => [
|
'media' => [
|
||||||
'not_an_image' => 'El fichero no es una imagen JPEG, PNG, WebP, GIF, AVIF o SVG.',
|
'not_an_image' => 'El fichero no es una imagen JPEG, PNG, WebP, GIF, AVIF o SVG.',
|
||||||
'remote' => [
|
'remote' => [
|
||||||
|
|||||||
@@ -106,6 +106,12 @@ tags:
|
|||||||
description: Translatable page content
|
description: Translatable page content
|
||||||
- name: Categories
|
- name: Categories
|
||||||
description: Translatable page categories
|
description: Translatable page categories
|
||||||
|
- name: Projects
|
||||||
|
description: The collective's work — translatable, with its team and stack
|
||||||
|
- name: Members
|
||||||
|
description: The people in the collective
|
||||||
|
- name: Services
|
||||||
|
description: What the collective offers
|
||||||
- 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
|
||||||
@@ -1279,6 +1285,366 @@ paths:
|
|||||||
"204": { description: Deleted }
|
"204": { description: Deleted }
|
||||||
"403": { $ref: "#/components/responses/Forbidden" }
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
|
/v1/projects:
|
||||||
|
get:
|
||||||
|
tags: [Projects]
|
||||||
|
summary: List projects
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/Lang"
|
||||||
|
- $ref: "#/components/parameters/With"
|
||||||
|
- $ref: "#/components/parameters/PerPage"
|
||||||
|
- name: status
|
||||||
|
in: query
|
||||||
|
schema: { type: string, enum: [idea, active, paused, completed, archived] }
|
||||||
|
- name: search
|
||||||
|
in: query
|
||||||
|
description: Matches the title within the active language only.
|
||||||
|
schema: { type: string }
|
||||||
|
- name: trashed
|
||||||
|
in: query
|
||||||
|
schema: { type: boolean }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Paginated projects
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ProjectCollection" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
post:
|
||||||
|
tags: [Projects]
|
||||||
|
summary: Create a project
|
||||||
|
description: |
|
||||||
|
Relations are sent as ids and replaced whole when present:
|
||||||
|
`technology_ids`, `service_ids`, and `members` with each one's role.
|
||||||
|
`published_at` and `unpublish_at` require `projects.publish`. For a
|
||||||
|
cover, upload it with `POST /v1/media` and `for: projects`.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ProjectWrite" }
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Project created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Project" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"422": { $ref: "#/components/responses/ValidationError" }
|
||||||
|
|
||||||
|
/v1/projects/{project}:
|
||||||
|
parameters:
|
||||||
|
- name: project
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: integer }
|
||||||
|
get:
|
||||||
|
tags: [Projects]
|
||||||
|
summary: Show a project
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/Lang"
|
||||||
|
- $ref: "#/components/parameters/With"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: The project
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Project" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"404": { description: Not found }
|
||||||
|
patch:
|
||||||
|
tags: [Projects]
|
||||||
|
summary: Update a project
|
||||||
|
description: Partial update. Translatable fields merge into the existing map.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ProjectWrite" }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Project updated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Project" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"422": { $ref: "#/components/responses/ValidationError" }
|
||||||
|
delete:
|
||||||
|
tags: [Projects]
|
||||||
|
summary: Soft-delete a project
|
||||||
|
responses:
|
||||||
|
"204": { description: Deleted }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
|
/v1/projects/{project}/restore:
|
||||||
|
post:
|
||||||
|
tags: [Projects]
|
||||||
|
summary: Restore a soft-deleted project
|
||||||
|
parameters:
|
||||||
|
- name: project
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: integer }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Project restored
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Project" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
|
/v1/members:
|
||||||
|
get:
|
||||||
|
tags: [Members]
|
||||||
|
summary: List members
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/Lang"
|
||||||
|
- $ref: "#/components/parameters/With"
|
||||||
|
- $ref: "#/components/parameters/PerPage"
|
||||||
|
- name: search
|
||||||
|
in: query
|
||||||
|
description: Matches the name.
|
||||||
|
schema: { type: string }
|
||||||
|
- name: is_public
|
||||||
|
in: query
|
||||||
|
schema: { type: boolean }
|
||||||
|
- name: trashed
|
||||||
|
in: query
|
||||||
|
schema: { type: boolean }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Paginated members
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/MemberCollection" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
post:
|
||||||
|
tags: [Members]
|
||||||
|
summary: Create a member
|
||||||
|
description: |
|
||||||
|
`links` is a label → URL map. `technology_ids` is replaced whole when
|
||||||
|
present; which projects someone is on is set from the project, where
|
||||||
|
the role belongs. For a photo, upload it with `POST /v1/media` and
|
||||||
|
`for: members`. Someone may edit their own profile without
|
||||||
|
`members.update`.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/MemberWrite" }
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Member created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Member" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"422": { $ref: "#/components/responses/ValidationError" }
|
||||||
|
|
||||||
|
/v1/members/{member}:
|
||||||
|
parameters:
|
||||||
|
- name: member
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: integer }
|
||||||
|
get:
|
||||||
|
tags: [Members]
|
||||||
|
summary: Show a member
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/Lang"
|
||||||
|
- $ref: "#/components/parameters/With"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: The member
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Member" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"404": { description: Not found }
|
||||||
|
patch:
|
||||||
|
tags: [Members]
|
||||||
|
summary: Update a member
|
||||||
|
description: Partial update. Translatable fields merge into the existing map.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/MemberWrite" }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Member updated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Member" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"422": { $ref: "#/components/responses/ValidationError" }
|
||||||
|
delete:
|
||||||
|
tags: [Members]
|
||||||
|
summary: Soft-delete a member
|
||||||
|
responses:
|
||||||
|
"204": { description: Deleted }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
|
/v1/members/{member}/restore:
|
||||||
|
post:
|
||||||
|
tags: [Members]
|
||||||
|
summary: Restore a soft-deleted member
|
||||||
|
parameters:
|
||||||
|
- name: member
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: integer }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Member restored
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Member" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
|
/v1/services:
|
||||||
|
get:
|
||||||
|
tags: [Services]
|
||||||
|
summary: List services
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/Lang"
|
||||||
|
- $ref: "#/components/parameters/With"
|
||||||
|
- $ref: "#/components/parameters/PerPage"
|
||||||
|
- name: is_published
|
||||||
|
in: query
|
||||||
|
schema: { type: boolean }
|
||||||
|
- name: search
|
||||||
|
in: query
|
||||||
|
description: Matches the title within the active language only.
|
||||||
|
schema: { type: string }
|
||||||
|
- name: trashed
|
||||||
|
in: query
|
||||||
|
schema: { type: boolean }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Paginated services
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ServiceCollection" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
post:
|
||||||
|
tags: [Services]
|
||||||
|
summary: Create a service
|
||||||
|
description: |
|
||||||
|
`technology_ids` and `project_ids` are replaced whole when present.
|
||||||
|
`is_published`, `published_at` and `unpublish_at` require
|
||||||
|
`services.publish`.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ServiceWrite" }
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Service created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Service" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"422": { $ref: "#/components/responses/ValidationError" }
|
||||||
|
|
||||||
|
/v1/services/{service}:
|
||||||
|
parameters:
|
||||||
|
- name: service
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: integer }
|
||||||
|
get:
|
||||||
|
tags: [Services]
|
||||||
|
summary: Show a service
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/Lang"
|
||||||
|
- $ref: "#/components/parameters/With"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: The service
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Service" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"404": { description: Not found }
|
||||||
|
patch:
|
||||||
|
tags: [Services]
|
||||||
|
summary: Update a service
|
||||||
|
description: Partial update. Translatable fields merge into the existing map.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ServiceWrite" }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Service updated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Service" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"422": { $ref: "#/components/responses/ValidationError" }
|
||||||
|
delete:
|
||||||
|
tags: [Services]
|
||||||
|
summary: Soft-delete a service
|
||||||
|
responses:
|
||||||
|
"204": { description: Deleted }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
|
/v1/services/{service}/restore:
|
||||||
|
post:
|
||||||
|
tags: [Services]
|
||||||
|
summary: Restore a soft-deleted service
|
||||||
|
parameters:
|
||||||
|
- name: service
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: integer }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Service restored
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Service" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
/v1/media:
|
/v1/media:
|
||||||
post:
|
post:
|
||||||
tags: [Media]
|
tags: [Media]
|
||||||
@@ -2295,9 +2661,220 @@ components:
|
|||||||
nullable: true
|
nullable: true
|
||||||
description: Must be after `published_at`. When it arrives the record leaves the site on its own.
|
description: Must be after `published_at`. When it arrives the record leaves the site on its own.
|
||||||
|
|
||||||
|
Project:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id: { type: integer }
|
||||||
|
locale: { type: string, description: The language the fields below were resolved into. }
|
||||||
|
title: { type: string, nullable: true }
|
||||||
|
slug: { type: string, nullable: true }
|
||||||
|
tagline: { type: string, nullable: true }
|
||||||
|
description: { type: string, nullable: true, description: HTML from the panel's rich editor. }
|
||||||
|
decisions: { type: string, nullable: true, description: HTML. Why the technical choices were made. }
|
||||||
|
postmortem: { type: string, nullable: true, description: HTML. What went wrong and what was learned. }
|
||||||
|
meta_title: { type: string, nullable: true }
|
||||||
|
meta_description: { type: string, nullable: true }
|
||||||
|
cover_image_alt: { type: string, nullable: true }
|
||||||
|
status: { type: string, enum: [idea, active, paused, completed, archived] }
|
||||||
|
is_featured: { type: boolean }
|
||||||
|
needs_help: { type: boolean }
|
||||||
|
demo_url: { type: string, nullable: true, format: uri }
|
||||||
|
cover_image: { type: string, nullable: true, description: The stored path — what a client PATCHes back. }
|
||||||
|
cover_image_media:
|
||||||
|
allOf: [{ $ref: "#/components/schemas/Image" }]
|
||||||
|
nullable: true
|
||||||
|
readOnly: true
|
||||||
|
started_at: { type: string, format: date, nullable: true }
|
||||||
|
ended_at: { type: string, format: date, nullable: true }
|
||||||
|
published_at: { type: string, format: date-time, nullable: true, description: On the site from then on; null keeps it off. }
|
||||||
|
unpublish_at: { type: string, format: date-time, nullable: true }
|
||||||
|
position: { type: integer }
|
||||||
|
client_id: { type: integer, nullable: true }
|
||||||
|
lead_member_id: { type: integer, nullable: true }
|
||||||
|
technology_ids: { type: array, items: { type: integer } }
|
||||||
|
service_ids: { type: array, items: { type: integer } }
|
||||||
|
members:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id: { type: integer }
|
||||||
|
name: { type: string }
|
||||||
|
role: { type: string, 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`." }
|
||||||
|
|
||||||
|
ProjectWrite:
|
||||||
|
type: object
|
||||||
|
required: [title, slug]
|
||||||
|
properties:
|
||||||
|
title: { $ref: "#/components/schemas/TranslatedField" }
|
||||||
|
slug:
|
||||||
|
allOf: [{ $ref: "#/components/schemas/TranslatedField" }]
|
||||||
|
description: Unique per language.
|
||||||
|
tagline: { $ref: "#/components/schemas/TranslatedField" }
|
||||||
|
description: { $ref: "#/components/schemas/TranslatedField" }
|
||||||
|
decisions: { $ref: "#/components/schemas/TranslatedField" }
|
||||||
|
postmortem: { $ref: "#/components/schemas/TranslatedField" }
|
||||||
|
meta_title: { $ref: "#/components/schemas/TranslatedField" }
|
||||||
|
meta_description: { $ref: "#/components/schemas/TranslatedField" }
|
||||||
|
cover_image_alt: { $ref: "#/components/schemas/TranslatedField" }
|
||||||
|
status: { type: string, enum: [idea, active, paused, completed, archived] }
|
||||||
|
is_featured: { type: boolean }
|
||||||
|
needs_help: { type: boolean }
|
||||||
|
demo_url: { type: string, format: uri, nullable: true }
|
||||||
|
cover_image: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=projects`. }
|
||||||
|
started_at: { type: string, format: date, nullable: true }
|
||||||
|
ended_at: { type: string, format: date, nullable: true }
|
||||||
|
published_at: { type: string, format: date-time, nullable: true, description: Requires `projects.publish`. }
|
||||||
|
unpublish_at: { type: string, format: date-time, nullable: true, description: Requires `projects.publish`. }
|
||||||
|
position: { type: integer, minimum: 0 }
|
||||||
|
client_id: { type: integer, nullable: true }
|
||||||
|
lead_member_id: { type: integer, nullable: true }
|
||||||
|
technology_ids: { type: array, items: { type: integer } }
|
||||||
|
service_ids: { type: array, items: { type: integer } }
|
||||||
|
members:
|
||||||
|
type: array
|
||||||
|
description: The whole team, replacing the previous one.
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [id]
|
||||||
|
properties:
|
||||||
|
id: { type: integer }
|
||||||
|
role: { type: string, nullable: true, maxLength: 120 }
|
||||||
|
|
||||||
|
ProjectCollection:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/Project" }
|
||||||
|
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||||
|
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
||||||
|
|
||||||
|
Member:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id: { type: integer }
|
||||||
|
locale: { type: string }
|
||||||
|
name: { type: string }
|
||||||
|
slug: { type: string }
|
||||||
|
headline: { type: string, nullable: true }
|
||||||
|
bio: { type: string, nullable: true, description: Plain text. }
|
||||||
|
avatar: { type: string, nullable: true, description: The stored path. }
|
||||||
|
avatar_media:
|
||||||
|
allOf: [{ $ref: "#/components/schemas/Image" }]
|
||||||
|
nullable: true
|
||||||
|
readOnly: true
|
||||||
|
email: { type: string, nullable: true, description: Never shown on the site. }
|
||||||
|
github_username: { type: string, nullable: true }
|
||||||
|
links: { type: object, additionalProperties: { type: string }, description: Label → URL. }
|
||||||
|
is_public: { type: boolean }
|
||||||
|
is_founder: { type: boolean }
|
||||||
|
joined_at: { type: string, format: date, nullable: true }
|
||||||
|
left_at: { type: string, format: date, nullable: true }
|
||||||
|
position: { type: integer }
|
||||||
|
technology_ids: { type: array, items: { type: integer } }
|
||||||
|
projects:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id: { type: integer }
|
||||||
|
title: { type: string, nullable: true }
|
||||||
|
role: { type: string, 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`." }
|
||||||
|
|
||||||
|
MemberWrite:
|
||||||
|
type: object
|
||||||
|
required: [name, slug]
|
||||||
|
properties:
|
||||||
|
name: { type: string, maxLength: 255 }
|
||||||
|
slug: { type: string, maxLength: 255, pattern: "^[A-Za-z0-9_-]+$" }
|
||||||
|
headline: { $ref: "#/components/schemas/TranslatedField" }
|
||||||
|
bio: { $ref: "#/components/schemas/TranslatedField" }
|
||||||
|
avatar: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=members`. }
|
||||||
|
email: { type: string, format: email, nullable: true }
|
||||||
|
github_username: { type: string, nullable: true, maxLength: 120 }
|
||||||
|
links: { type: object, nullable: true, additionalProperties: { type: string, format: uri } }
|
||||||
|
is_public: { type: boolean }
|
||||||
|
is_founder: { type: boolean }
|
||||||
|
joined_at: { type: string, format: date, nullable: true }
|
||||||
|
left_at: { type: string, format: date, nullable: true }
|
||||||
|
position: { type: integer, minimum: 0 }
|
||||||
|
technology_ids: { type: array, items: { type: integer } }
|
||||||
|
|
||||||
|
MemberCollection:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/Member" }
|
||||||
|
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||||
|
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
||||||
|
|
||||||
|
Service:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id: { type: integer }
|
||||||
|
locale: { type: string }
|
||||||
|
title: { type: string, nullable: true }
|
||||||
|
slug: { type: string, nullable: true }
|
||||||
|
tagline: { type: string, nullable: true }
|
||||||
|
description: { type: string, nullable: true, description: Plain text. }
|
||||||
|
meta_title: { type: string, nullable: true }
|
||||||
|
meta_description: { type: string, nullable: true }
|
||||||
|
icon: { type: string, nullable: true }
|
||||||
|
is_featured: { type: boolean }
|
||||||
|
is_published: { type: boolean }
|
||||||
|
published_at: { type: string, format: date-time, nullable: true }
|
||||||
|
unpublish_at: { type: string, format: date-time, nullable: true }
|
||||||
|
position: { type: integer }
|
||||||
|
technology_ids: { type: array, items: { type: integer } }
|
||||||
|
project_ids: { type: array, items: { 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`." }
|
||||||
|
|
||||||
|
ServiceWrite:
|
||||||
|
type: object
|
||||||
|
required: [title, slug]
|
||||||
|
properties:
|
||||||
|
title: { $ref: "#/components/schemas/TranslatedField" }
|
||||||
|
slug:
|
||||||
|
allOf: [{ $ref: "#/components/schemas/TranslatedField" }]
|
||||||
|
description: Unique per language.
|
||||||
|
tagline: { $ref: "#/components/schemas/TranslatedField" }
|
||||||
|
description: { $ref: "#/components/schemas/TranslatedField" }
|
||||||
|
meta_title: { $ref: "#/components/schemas/TranslatedField" }
|
||||||
|
meta_description: { $ref: "#/components/schemas/TranslatedField" }
|
||||||
|
icon: { type: string, nullable: true, maxLength: 120, description: "An icon name, e.g. `support`." }
|
||||||
|
is_featured: { type: boolean }
|
||||||
|
position: { type: integer, minimum: 0 }
|
||||||
|
is_published: { type: boolean, description: Requires `services.publish`. }
|
||||||
|
published_at: { type: string, format: date-time, nullable: true, description: Requires `services.publish`. }
|
||||||
|
unpublish_at: { type: string, format: date-time, nullable: true, description: Requires `services.publish`. }
|
||||||
|
technology_ids: { type: array, items: { type: integer } }
|
||||||
|
project_ids: { type: array, items: { type: integer } }
|
||||||
|
|
||||||
|
ServiceCollection:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/Service" }
|
||||||
|
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||||
|
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
||||||
|
|
||||||
MediaFor:
|
MediaFor:
|
||||||
type: string
|
type: string
|
||||||
enum: [pages, technologies]
|
enum: [pages, technologies, projects, members]
|
||||||
default: pages
|
default: pages
|
||||||
description: The kind of record the image is for.
|
description: The kind of record the image is for.
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ 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\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\PageController;
|
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\RoleController;
|
||||||
|
use App\Http\Controllers\Api\V1\ServiceController;
|
||||||
use App\Http\Controllers\Api\V1\TechnologyController;
|
use App\Http\Controllers\Api\V1\TechnologyController;
|
||||||
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;
|
||||||
@@ -169,6 +172,21 @@ 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
|
||||||
|
// be brought back as a page can.
|
||||||
|
foreach ([
|
||||||
|
'projects' => ProjectController::class,
|
||||||
|
'members' => MemberController::class,
|
||||||
|
'services' => ServiceController::class,
|
||||||
|
] as $resource => $controller) {
|
||||||
|
$parameter = str($resource)->singular()->toString();
|
||||||
|
|
||||||
|
Route::apiResource($resource, $controller);
|
||||||
|
Route::post("{$resource}/{{$parameter}}/restore", [$controller, 'restore'])
|
||||||
|
->withTrashed()
|
||||||
|
->name("{$resource}.restore");
|
||||||
|
}
|
||||||
|
|
||||||
// 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,7 +5,10 @@ declare(strict_types=1);
|
|||||||
namespace Tests\Feature\Api;
|
namespace Tests\Feature\Api;
|
||||||
|
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
|
use App\Models\Member;
|
||||||
use App\Models\Page;
|
use App\Models\Page;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Service;
|
||||||
use App\Models\Technology;
|
use App\Models\Technology;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Database\Seeders\RolePermissionSeeder;
|
use Database\Seeders\RolePermissionSeeder;
|
||||||
@@ -66,6 +69,9 @@ class AdminApiContractTest extends TestCase
|
|||||||
'categories' => ['path' => '/v1/categories'],
|
'categories' => ['path' => '/v1/categories'],
|
||||||
'users' => ['path' => '/v1/users'],
|
'users' => ['path' => '/v1/users'],
|
||||||
'technologies' => ['path' => '/v1/technologies'],
|
'technologies' => ['path' => '/v1/technologies'],
|
||||||
|
'projects' => ['path' => '/v1/projects'],
|
||||||
|
'members' => ['path' => '/v1/members'],
|
||||||
|
'services' => ['path' => '/v1/services'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +81,9 @@ class AdminApiContractTest extends TestCase
|
|||||||
$page = Page::factory()->create();
|
$page = Page::factory()->create();
|
||||||
$category = Category::factory()->create();
|
$category = Category::factory()->create();
|
||||||
$technology = Technology::factory()->create();
|
$technology = Technology::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$member = Member::factory()->create();
|
||||||
|
$service = Service::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
|
||||||
@@ -87,6 +96,12 @@ class AdminApiContractTest extends TestCase
|
|||||||
'category' => ["/v1/categories/{$category->id}", '/v1/categories/{category}'],
|
'category' => ["/v1/categories/{$category->id}", '/v1/categories/{category}'],
|
||||||
'technologies' => ['/v1/technologies', '/v1/technologies'],
|
'technologies' => ['/v1/technologies', '/v1/technologies'],
|
||||||
'technology' => ["/v1/technologies/{$technology->id}", '/v1/technologies/{technology}'],
|
'technology' => ["/v1/technologies/{$technology->id}", '/v1/technologies/{technology}'],
|
||||||
|
'projects' => ['/v1/projects', '/v1/projects'],
|
||||||
|
'project' => ["/v1/projects/{$project->id}", '/v1/projects/{project}'],
|
||||||
|
'members' => ['/v1/members', '/v1/members'],
|
||||||
|
'member' => ["/v1/members/{$member->id}", '/v1/members/{member}'],
|
||||||
|
'services' => ['/v1/services', '/v1/services'],
|
||||||
|
'service' => ["/v1/services/{$service->id}", '/v1/services/{service}'],
|
||||||
'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'],
|
||||||
|
|||||||
257
tests/Feature/Api/CollectiveApiTest.php
Normal file
257
tests/Feature/Api/CollectiveApiTest.php
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api;
|
||||||
|
|
||||||
|
use App\Enums\ProjectStatus;
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\Technology;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\RolePermissionSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\FakesPireon;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projects, members and services through the admin API: what the MCP server
|
||||||
|
* uses to load the collective's work without anyone retyping it into forms.
|
||||||
|
*/
|
||||||
|
class CollectiveApiTest 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_project_arrives_with_its_team_stack_client_and_services(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
[$ada, $linus] = Member::factory()->count(2)->create();
|
||||||
|
$technologies = Technology::factory()->count(2)->create();
|
||||||
|
$client = Client::factory()->create();
|
||||||
|
$service = Service::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v1/projects?lang=es&with=translations', [
|
||||||
|
'title' => ['es' => 'Catálogo abierto', 'en' => 'Open catalogue'],
|
||||||
|
'slug' => ['es' => 'catalogo', 'en' => 'catalogue'],
|
||||||
|
'tagline' => ['es' => 'Una herramienta pequeña.'],
|
||||||
|
'status' => 'active',
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'lead_member_id' => $ada->id,
|
||||||
|
'technology_ids' => $technologies->pluck('id')->all(),
|
||||||
|
'service_ids' => [$service->id],
|
||||||
|
'members' => [['id' => $ada->id, 'role' => 'Responsable'], ['id' => $linus->id]],
|
||||||
|
])->assertCreated();
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertJsonPath('data.title', 'Catálogo abierto')
|
||||||
|
->assertJsonPath('data.translations.title.en', 'Open catalogue')
|
||||||
|
->assertJsonPath('data.status', 'active')
|
||||||
|
->assertJsonPath('data.client_id', $client->id)
|
||||||
|
->assertJsonCount(2, 'data.technology_ids')
|
||||||
|
->assertJsonPath('data.members.0.role', 'Responsable');
|
||||||
|
|
||||||
|
$project = Project::query()->sole();
|
||||||
|
|
||||||
|
$this->assertSame('Responsable', $project->members()->find($ada->id)->pivot->role);
|
||||||
|
$this->assertNull($project->members()->find($linus->id)->pivot->role);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_partial_update_leaves_relations_alone_unless_they_are_sent(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$technology = Technology::factory()->create();
|
||||||
|
$project->technologies()->attach($technology);
|
||||||
|
|
||||||
|
$this->patchJson("/api/v1/projects/{$project->id}", ['status' => 'paused'])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.technology_ids', [$technology->id]);
|
||||||
|
|
||||||
|
$this->patchJson("/api/v1/projects/{$project->id}", ['technology_ids' => []])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.technology_ids', []);
|
||||||
|
|
||||||
|
$this->assertSame(ProjectStatus::Paused, $project->fresh()->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function writing_a_project_is_not_publishing_it(): void
|
||||||
|
{
|
||||||
|
// A writer authors projects but may not put them on the site.
|
||||||
|
$this->actingWithRole('writer');
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/projects', [
|
||||||
|
'title' => ['es' => 'Borrador'],
|
||||||
|
'slug' => ['es' => 'borrador'],
|
||||||
|
'published_at' => now()->toIso8601String(),
|
||||||
|
])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('published_at');
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/projects', [
|
||||||
|
'title' => ['es' => 'Borrador'],
|
||||||
|
'slug' => ['es' => 'borrador'],
|
||||||
|
])->assertCreated();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function an_editor_may_publish_a_project(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/projects', [
|
||||||
|
'title' => ['es' => 'Publicado'],
|
||||||
|
'slug' => ['es' => 'publicado'],
|
||||||
|
'published_at' => '2026-09-01T10:00:00Z',
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.published_at', '2026-09-01T10:00:00+00:00');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_deleted_project_can_be_brought_back(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
|
||||||
|
$this->deleteJson("/api/v1/projects/{$project->id}")->assertNoContent();
|
||||||
|
$this->assertSoftDeleted($project);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/projects?trashed=1')->assertJsonCount(1, 'data');
|
||||||
|
|
||||||
|
$this->postJson("/api/v1/projects/{$project->id}/restore")->assertOk();
|
||||||
|
$this->assertNotSoftDeleted($project);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_member_arrives_with_links_and_technologies(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$technology = Technology::factory()->create();
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/members?lang=es', [
|
||||||
|
'name' => 'Ada Lovelace',
|
||||||
|
'slug' => 'ada',
|
||||||
|
'headline' => ['es' => 'Backend', 'en' => 'Backend'],
|
||||||
|
'github_username' => 'ada',
|
||||||
|
'links' => ['web' => 'https://ada.example.org'],
|
||||||
|
'is_founder' => true,
|
||||||
|
'joined_at' => '2024-09-01',
|
||||||
|
'technology_ids' => [$technology->id],
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.headline', 'Backend')
|
||||||
|
->assertJsonPath('data.links.web', 'https://ada.example.org')
|
||||||
|
->assertJsonPath('data.joined_at', '2024-09-01')
|
||||||
|
->assertJsonPath('data.technology_ids', [$technology->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function member_slugs_and_links_are_checked(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
Member::factory()->create(['slug' => 'ada']);
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/members', ['name' => 'Ada', 'slug' => 'ada'])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('slug');
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/members', ['name' => 'Bob', 'slug' => 'bob', 'links' => ['web' => 'not a url']])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('links.web');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function someone_may_edit_their_own_profile_without_members_update(): void
|
||||||
|
{
|
||||||
|
$viewer = $this->actingWithRole('viewer');
|
||||||
|
|
||||||
|
$own = Member::factory()->create(['user_id' => $viewer->id]);
|
||||||
|
$someoneElse = Member::factory()->create();
|
||||||
|
|
||||||
|
$this->patchJson("/api/v1/members/{$own->id}", ['github_username' => 'me'])->assertOk();
|
||||||
|
$this->patchJson("/api/v1/members/{$someoneElse->id}", ['github_username' => 'them'])->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_service_links_to_the_work_behind_it(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$technology = Technology::factory()->create();
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/services?lang=es', [
|
||||||
|
'title' => ['es' => 'Formación'],
|
||||||
|
'slug' => ['es' => 'formacion'],
|
||||||
|
'tagline' => ['es' => 'Talleres prácticos.'],
|
||||||
|
'icon' => 'support',
|
||||||
|
'is_published' => true,
|
||||||
|
'project_ids' => [$project->id],
|
||||||
|
'technology_ids' => [$technology->id],
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.title', 'Formación')
|
||||||
|
->assertJsonPath('data.is_published', true)
|
||||||
|
->assertJsonPath('data.project_ids', [$project->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function writing_a_service_is_not_publishing_it(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('writer');
|
||||||
|
|
||||||
|
$service = Service::factory()->create(['is_published' => false]);
|
||||||
|
|
||||||
|
$this->patchJson("/api/v1/services/{$service->id}", ['is_published' => true])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('is_published');
|
||||||
|
|
||||||
|
$this->patchJson("/api/v1/services/{$service->id}", ['tagline' => ['es' => 'Nuevo texto']])
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$this->assertFalse($service->fresh()->is_published);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_reader_can_list_but_not_write(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('viewer');
|
||||||
|
|
||||||
|
Project::factory()->create();
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/projects')->assertOk()->assertJsonCount(1, 'data');
|
||||||
|
$this->getJson('/api/v1/services')->assertOk();
|
||||||
|
$this->getJson('/api/v1/members')->assertOk();
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/projects', ['title' => ['es' => 'X'], 'slug' => ['es' => 'x']])->assertForbidden();
|
||||||
|
$this->postJson('/api/v1/services', ['title' => ['es' => 'X'], 'slug' => ['es' => 'x']])->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user