Compare commits

...

3 Commits

Author SHA1 Message Date
82ef2b38dd Manage projects, members and services through the API
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
/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>
2026-09-29 16:07:20 +02:00
6b67c7afd4 Give the test suite the memory it now needs
The full run died with "Premature end of PHP process" on machines whose
php.ini keeps PHP's usual 128M: no single test needs more, but the
panel tests boot Filament again and again in one process, and the
suite outgrew it. Always in a panel test, which is why it looked like
one of them was flaky. Stated in phpunit.xml so it runs the same
everywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:07:19 +02:00
7e672a5e62 Serve uploaded SVGs under their CSP, and identify the image fetcher
The Content-Security-Policy meant for stored SVGs never applied: in
Caddy a `*` in the middle of a path does not cross a `/`, so
`path /storage/*.svg` only matched files directly under /storage, and
every upload lives in a directory below it. Two matchers, which Caddy
ANDs, cover any depth.

RemoteImage fetched with Guzzle's anonymous default User-Agent, which
Wikimedia answers with 403. It now sends `<app>/1.0 (+<app url>; media
fetcher)`, the identification such hosts ask of automated clients.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:43:59 +02:00
23 changed files with 1712 additions and 2 deletions

View File

@@ -1263,11 +1263,21 @@ POST /api/v1/pages/{page}/restore
GET /api/v1/users PATCH para roles, is_active, locale
GET /api/v1/roles solo lectura
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,
radar_ring (none = fuera del radar), search
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
cliente en vez de comprobar nombres de rol.

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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'));
}
}
});
}
}

View 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')],
];
}
}

View 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']);
}
}

View 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']);
}
}

View 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),
),
];
}
}

View 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),
),
];
}
}

View 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),
),
];
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Services\Media;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/**
@@ -25,6 +26,10 @@ use Illuminate\Validation\ValidationException;
* - Redirects are not followed: the target of one would need all of the above
* again, and "use the final address" is an easy thing to ask for.
* - The body is read to a ceiling and no further, whatever the headers claim.
*
* It says who is asking. Hosts such as Wikimedia refuse Guzzle's anonymous
* default outright, and a User-Agent with a contact address is what their
* policies ask of anything that fetches automatically.
*/
class RemoteImage
{
@@ -63,6 +68,7 @@ class RemoteImage
)]],
])
->withHeaders(['Accept' => 'image/*'])
->withUserAgent($this->userAgent())
->get($url);
} catch (ConnectionException) {
$this->fail('unreachable');
@@ -94,6 +100,19 @@ class RemoteImage
return $contents;
}
/**
* `pireon-admin/1.0 (+https://admin.example.org; media fetcher)`: a product
* token without spaces, and the installation's own address to reach it by.
*/
protected function userAgent(): string
{
return sprintf(
'%s/1.0 (+%s; media fetcher)',
Str::slug((string) config('app.name')) ?: 'landing',
rtrim((string) config('app.url'), '/'),
);
}
/**
* The address to connect to, once every address the host has is known to
* be public.

View File

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

View File

@@ -25,7 +25,15 @@
# second line: opened directly rather than through an <img>, an SVG is a
# document, and nothing stored here has any business running script or
# loading anything from elsewhere.
@svg path /storage/*.svg
#
# Two matchers, which Caddy ANDs, rather than `path /storage/*.svg`: a `*`
# in the middle of a path does not cross a `/`, so that pattern only ever
# matched files directly under /storage — and every upload is a directory
# below it.
@svg {
path /storage/*
path_regexp (?i)\.svg$
}
header @svg Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; img-src data:"
# The frontend is a separate application on another origin.

View File

@@ -29,6 +29,10 @@ return [
'cannot_publish' => 'No tens permís per publicar pàgines.',
],
'publication' => [
'forbidden' => 'No tens permís per publicar ni despublicar això.',
],
'media' => [
'not_an_image' => 'El fitxer no és una imatge JPEG, PNG, WebP, GIF, AVIF o SVG.',
'remote' => [

View File

@@ -29,6 +29,10 @@ return [
'cannot_publish' => 'You do not have permission to publish pages.',
],
'publication' => [
'forbidden' => 'You do not have permission to publish or unpublish this.',
],
'media' => [
'not_an_image' => 'The file is not a JPEG, PNG, WebP, GIF, AVIF or SVG image.',
'remote' => [

View File

@@ -29,6 +29,10 @@ return [
'cannot_publish' => 'No tienes permiso para publicar páginas.',
],
'publication' => [
'forbidden' => 'No tienes permiso para publicar ni despublicar esto.',
],
'media' => [
'not_an_image' => 'El fichero no es una imagen JPEG, PNG, WebP, GIF, AVIF o SVG.',
'remote' => [

View File

@@ -18,6 +18,10 @@
</include>
</source>
<php>
<!-- The suite, not any one test, outgrew PHP's usual 128M: the panel
tests boot Filament again and again in one process. Stated here so
it runs the same on every machine, whatever its php.ini says. -->
<ini name="memory_limit" value="512M"/>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>

View File

@@ -106,6 +106,12 @@ tags:
description: Translatable page content
- name: 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
description: What the collective uses, and its place on the tech radar
- name: Users
@@ -1279,6 +1285,366 @@ paths:
"204": { description: Deleted }
"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:
post:
tags: [Media]
@@ -2295,9 +2661,220 @@ components:
nullable: true
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:
type: string
enum: [pages, technologies]
enum: [pages, technologies, projects, members]
default: pages
description: The kind of record the image is for.

View File

@@ -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\MeController;
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\ProjectController;
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\TokenController;
use App\Http\Controllers\Api\V1\UserController;
@@ -169,6 +172,21 @@ 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
// 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.
Route::post('media', [MediaController::class, 'store'])->name('media.store');

View File

@@ -5,7 +5,10 @@ declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Models\Category;
use App\Models\Member;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use App\Models\Technology;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
@@ -66,6 +69,9 @@ class AdminApiContractTest extends TestCase
'categories' => ['path' => '/v1/categories'],
'users' => ['path' => '/v1/users'],
'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();
$category = Category::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
// 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}'],
'technologies' => ['/v1/technologies', '/v1/technologies'],
'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'],
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
'me' => ['/v1/me', '/v1/me'],

View 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();
}
}

View File

@@ -133,6 +133,26 @@ class MediaUploadTest extends TestCase
->assertJsonPath('data.width', 30);
}
#[Test]
public function it_says_who_is_fetching(): void
{
config()->set('app.name', 'Pireon Admin');
config()->set('app.url', 'https://admin.example.org');
$this->actingWithRole('editor');
$this->resolving(['images.example.test' => ['93.184.216.34']]);
Http::fake([
'https://images.example.test/*' => Http::response($this->png(), 200),
]);
$this->postJson('/api/v1/media', ['url' => 'https://images.example.test/a.png'])->assertCreated();
// Hosts such as Wikimedia answer Guzzle's anonymous default with 403.
Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://images.example.test/')
&& $request->header('User-Agent') === ['pireon-admin/1.0 (+https://admin.example.org; media fetcher)']);
}
/**
* @return array<string, array{0: array<string, array<int, string>>, 1: string}>
*/