From 82ef2b38ddaf35ea7f2971e93982818dffabf2d2 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Tue, 29 Sep 2026 16:07:20 +0200 Subject: [PATCH] Manage projects, members and services through the API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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 --- README.md | 10 + .../Controllers/Api/V1/MemberController.php | 103 ++++ .../Controllers/Api/V1/ProjectController.php | 122 ++++ .../Controllers/Api/V1/ServiceController.php | 107 ++++ .../Api/V1/Concerns/GuardsPublication.php | 33 + app/Http/Requests/Api/V1/MemberRequest.php | 66 ++ app/Http/Requests/Api/V1/ProjectRequest.php | 84 +++ app/Http/Requests/Api/V1/ServiceRequest.php | 68 ++ app/Http/Resources/MemberResource.php | 65 ++ app/Http/Resources/ProjectResource.php | 70 +++ app/Http/Resources/ServiceResource.php | 50 ++ config/media.php | 2 + lang/ca/rules.php | 4 + lang/en/rules.php | 4 + lang/es/rules.php | 4 + public/openapi.yaml | 579 +++++++++++++++++- routes/api.php | 18 + tests/Feature/Api/AdminApiContractTest.php | 15 + tests/Feature/Api/CollectiveApiTest.php | 257 ++++++++ 19 files changed, 1660 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Api/V1/MemberController.php create mode 100644 app/Http/Controllers/Api/V1/ProjectController.php create mode 100644 app/Http/Controllers/Api/V1/ServiceController.php create mode 100644 app/Http/Requests/Api/V1/Concerns/GuardsPublication.php create mode 100644 app/Http/Requests/Api/V1/MemberRequest.php create mode 100644 app/Http/Requests/Api/V1/ProjectRequest.php create mode 100644 app/Http/Requests/Api/V1/ServiceRequest.php create mode 100644 app/Http/Resources/MemberResource.php create mode 100644 app/Http/Resources/ProjectResource.php create mode 100644 app/Http/Resources/ServiceResource.php create mode 100644 tests/Feature/Api/CollectiveApiTest.php diff --git a/README.md b/README.md index 9a51be4..ebbddfe 100644 --- a/README.md +++ b/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. diff --git a/app/Http/Controllers/Api/V1/MemberController.php b/app/Http/Controllers/Api/V1/MemberController.php new file mode 100644 index 0000000..fec1350 --- /dev/null +++ b/app/Http/Controllers/Api/V1/MemberController.php @@ -0,0 +1,103 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/V1/ProjectController.php b/app/Http/Controllers/Api/V1/ProjectController.php new file mode 100644 index 0000000..94225b1 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ProjectController.php @@ -0,0 +1,122 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/V1/ServiceController.php b/app/Http/Controllers/Api/V1/ServiceController.php new file mode 100644 index 0000000..3512565 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ServiceController.php @@ -0,0 +1,107 @@ +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); + } +} diff --git a/app/Http/Requests/Api/V1/Concerns/GuardsPublication.php b/app/Http/Requests/Api/V1/Concerns/GuardsPublication.php new file mode 100644 index 0000000..78507e0 --- /dev/null +++ b/app/Http/Requests/Api/V1/Concerns/GuardsPublication.php @@ -0,0 +1,33 @@ + $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')); + } + } + }); + } +} diff --git a/app/Http/Requests/Api/V1/MemberRequest.php b/app/Http/Requests/Api/V1/MemberRequest.php new file mode 100644 index 0000000..03a1344 --- /dev/null +++ b/app/Http/Requests/Api/V1/MemberRequest.php @@ -0,0 +1,66 @@ + + */ + 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')], + ]; + } +} diff --git a/app/Http/Requests/Api/V1/ProjectRequest.php b/app/Http/Requests/Api/V1/ProjectRequest.php new file mode 100644 index 0000000..7ab01ba --- /dev/null +++ b/app/Http/Requests/Api/V1/ProjectRequest.php @@ -0,0 +1,84 @@ + + */ + 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']); + } +} diff --git a/app/Http/Requests/Api/V1/ServiceRequest.php b/app/Http/Requests/Api/V1/ServiceRequest.php new file mode 100644 index 0000000..aa8c5d4 --- /dev/null +++ b/app/Http/Requests/Api/V1/ServiceRequest.php @@ -0,0 +1,68 @@ + + */ + 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']); + } +} diff --git a/app/Http/Resources/MemberResource.php b/app/Http/Resources/MemberResource.php new file mode 100644 index 0000000..d18fa30 --- /dev/null +++ b/app/Http/Resources/MemberResource.php @@ -0,0 +1,65 @@ + + */ + 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), + ), + ]; + } +} diff --git a/app/Http/Resources/ProjectResource.php b/app/Http/Resources/ProjectResource.php new file mode 100644 index 0000000..9c59b60 --- /dev/null +++ b/app/Http/Resources/ProjectResource.php @@ -0,0 +1,70 @@ + + */ + 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), + ), + ]; + } +} diff --git a/app/Http/Resources/ServiceResource.php b/app/Http/Resources/ServiceResource.php new file mode 100644 index 0000000..d334dcc --- /dev/null +++ b/app/Http/Resources/ServiceResource.php @@ -0,0 +1,50 @@ + + */ + public function toArray(Request $request): array + { + $locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale(); + + return [ + 'id' => $this->id, + 'locale' => $locale, + + ...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), + ), + ]; + } +} diff --git a/config/media.php b/config/media.php index b88239b..4f05d2a 100644 --- a/config/media.php +++ b/config/media.php @@ -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 diff --git a/lang/ca/rules.php b/lang/ca/rules.php index a4a2686..bb43feb 100644 --- a/lang/ca/rules.php +++ b/lang/ca/rules.php @@ -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' => [ diff --git a/lang/en/rules.php b/lang/en/rules.php index cef56e1..210421c 100644 --- a/lang/en/rules.php +++ b/lang/en/rules.php @@ -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' => [ diff --git a/lang/es/rules.php b/lang/es/rules.php index 1d9f03e..c81edeb 100644 --- a/lang/es/rules.php +++ b/lang/es/rules.php @@ -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' => [ diff --git a/public/openapi.yaml b/public/openapi.yaml index fc2bb97..ed672f6 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -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. diff --git a/routes/api.php b/routes/api.php index 01e9e78..c219abd 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); diff --git a/tests/Feature/Api/AdminApiContractTest.php b/tests/Feature/Api/AdminApiContractTest.php index b23fa06..75e6863 100644 --- a/tests/Feature/Api/AdminApiContractTest.php +++ b/tests/Feature/Api/AdminApiContractTest.php @@ -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'], diff --git a/tests/Feature/Api/CollectiveApiTest.php b/tests/Feature/Api/CollectiveApiTest.php new file mode 100644 index 0000000..ecf8620 --- /dev/null +++ b/tests/Feature/Api/CollectiveApiTest.php @@ -0,0 +1,257 @@ +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(); + } +}