Compare commits
3 Commits
ac546eba2b
...
82ef2b38dd
| Author | SHA1 | Date | |
|---|---|---|---|
| 82ef2b38dd | |||
| 6b67c7afd4 | |||
| 7e672a5e62 |
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/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.
|
||||
|
||||
|
||||
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),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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}>
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user