Manage calls and the inbox through the API

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 19:42:59 +02:00
parent b02464a7ed
commit 4c65604f04
11 changed files with 890 additions and 1 deletions

View File

@@ -1273,6 +1273,12 @@ GET /api/v1/roles/catalogue catálogo de permisos agrupado
/api/v1/testimonials CRUD + restore; filtros: is_public, client_id, /api/v1/testimonials CRUD + restore; filtros: is_public, client_id,
project_id, service_id, search project_id, service_id, search
/api/v1/talks CRUD (sin papelera, como en el panel) /api/v1/talks CRUD (sin papelera, como en el panel)
/api/v1/calls CRUD; technology_ids; filtros: status,
project_id, search
GET /api/v1/applications el buzón; filtros: status (sin él, sin spam),
kind, call_id, search, trashed
PATCH /api/v1/applications/{id} solo status y notes
DELETE /api/v1/applications/{id} + POST …/restore
POST /api/v1/media subir una imagen (fichero o URL) POST /api/v1/media subir una imagen (fichero o URL)
``` ```

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\ApplicationStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\ApplicationRequest;
use App\Http\Resources\ApplicationResource;
use App\Models\Application;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* The inbox: what people sent from the public site.
*
* Nothing is created here — submissions arrive through the public API — and
* what can be changed is what the panel changes: the status and the private
* notes.
*/
class ApplicationController extends Controller
{
protected const WITH = ['call', 'handler'];
/**
* Filters: status, kind, call_id, search (name, e-mail or organisation), trashed.
*
* Spam stays out unless asked for by `status=spam`, as in the panel.
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Application::class);
$applications = Application::query()
->with(self::WITH)
->when(
$request->filled('status'),
fn ($q) => $q->where('status', $request->string('status')),
fn ($q) => $q->notSpam(),
)
->when($request->filled('kind'), fn ($q) => $q->where('kind', $request->string('kind')))
->when($request->filled('call_id'), fn ($q) => $q->where('call_id', $request->integer('call_id')))
->when($request->filled('search'), function ($q) use ($request): void {
$term = '%'.$request->string('search').'%';
$q->where(fn ($q) => $q
->where('name', 'like', $term)
->orWhere('email', 'like', $term)
->orWhere('organisation', 'like', $term));
})
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
->latest()
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return ApplicationResource::collection($applications);
}
public function show(Application $application): ApplicationResource
{
$this->authorize('view', $application);
return ApplicationResource::make($application->load(self::WITH));
}
public function update(ApplicationRequest $request, Application $application): ApplicationResource
{
$this->authorize('update', $application);
$data = $request->validated();
// Notes are outside the model's fillable list so the public form can
// never reach them; this is the one other place that writes them.
if (array_key_exists('notes', $data)) {
$application->forceFill(['notes' => $data['notes']])->save();
}
if (isset($data['status'])) {
$application->markAs(ApplicationStatus::from($data['status']), $request->user());
}
return ApplicationResource::make($application->load(self::WITH));
}
public function destroy(Application $application): JsonResponse
{
$this->authorize('delete', $application);
$application->delete();
return response()->json(status: 204);
}
public function restore(Application $application): ApplicationResource
{
$this->authorize('restore', $application);
$application->restore();
return ApplicationResource::make($application->load(self::WITH));
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\CallRequest;
use App\Http\Resources\CallResource;
use App\Models\Call;
use App\Support\TranslatablePayload;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;
/**
* Open calls for contributors. Deleted for good, like the panel does.
*/
class CallController extends Controller
{
protected const WITH = ['technologies'];
/**
* Filters: status, project_id, search (title in the active language).
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Call::class);
$locale = app()->getLocale();
$calls = Call::query()
->with(self::WITH)
->withCount('applications')
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
->when($request->filled('project_id'), fn ($q) => $q->where('project_id', $request->integer('project_id')))
->when($request->filled('search'), fn ($q) => $q->where("title->{$locale}", 'like', '%'.$request->string('search').'%'))
->latest('id')
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return CallResource::collection($calls);
}
public function store(CallRequest $request): JsonResponse
{
$this->authorize('create', Call::class);
return CallResource::make($this->save(new Call, $request))->response()->setStatusCode(201);
}
public function show(Call $call): CallResource
{
$this->authorize('view', $call);
return CallResource::make($call->load(self::WITH)->loadCount('applications'));
}
public function update(CallRequest $request, Call $call): CallResource
{
$this->authorize('update', $call);
return CallResource::make($this->save($call, $request));
}
public function destroy(Call $call): JsonResponse
{
$this->authorize('delete', $call);
$call->delete();
return response()->json(status: 204);
}
protected function save(Call $call, CallRequest $request): Call
{
$data = $request->validated();
DB::transaction(function () use ($call, $data): void {
$call->fill(TranslatablePayload::merge(
$call,
collect($data)->except(['technology_ids'])->all(),
$call->translatable,
));
$call->save();
if (array_key_exists('technology_ids', $data)) {
$call->technologies()->sync($data['technology_ids']);
}
});
return $call->load(self::WITH)->loadCount('applications');
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Enums\ApplicationStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* What the inbox lets anyone change about a submission: nothing the sender
* wrote, only how it is being handled.
*/
class ApplicationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'status' => ['sometimes', Rule::enum(ApplicationStatus::class)],
'notes' => ['sometimes', 'nullable', 'string', 'max:10000'],
];
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Enums\CallStatus;
use App\Models\Call;
use App\Rules\Translatable;
use App\Rules\UniqueTranslatedSlug;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* The panel's call form, as rules. `technology_ids` is replaced whole when
* sent.
*/
class CallRequest extends FormRequest
{
public const SENIORITIES = ['any', 'junior', 'mid', 'senior'];
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$call = $this->route('call');
$creating = ! $call instanceof Call;
$sometimes = $creating ? 'required' : 'sometimes';
return [
'title' => [$sometimes, new Translatable(requireDefaultLocale: $creating, max: 255)],
'slug' => [
$sometimes,
new Translatable(requireDefaultLocale: $creating, max: 255),
new UniqueTranslatedSlug(Call::class, $call?->id),
],
'description' => ['sometimes', 'nullable', new Translatable],
'what_you_get' => ['sometimes', 'nullable', new Translatable],
'status' => ['sometimes', Rule::enum(CallStatus::class)],
'hours_per_week' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:40'],
'duration_weeks' => ['sometimes', 'nullable', 'integer', 'min:1'],
'seniority' => ['sometimes', 'nullable', Rule::in(self::SENIORITIES)],
'opens_at' => ['sometimes', 'nullable', 'date'],
'closes_at' => ['sometimes', 'nullable', 'date', 'after_or_equal:opens_at'],
'project_id' => ['sometimes', 'nullable', 'integer', Rule::exists('projects', 'id')->whereNull('deleted_at')],
'technology_ids' => ['sometimes', 'array'],
'technology_ids.*' => ['integer', 'distinct', Rule::exists('technologies', 'id')],
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Application;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Application
*/
class ApplicationResource extends JsonResource
{
/**
* `ip_hash` stays out: it exists to spot the same sender twice, not to be
* handed around.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'kind' => $this->kind->value,
'status' => $this->status->value,
'call_id' => $this->call_id,
'subject' => $this->resource->subject(),
'name' => $this->name,
'email' => $this->email,
'organisation' => $this->organisation,
'message' => $this->message,
'hours_per_week' => $this->hours_per_week,
'links' => (object) ($this->links ?? []),
'locale' => $this->locale,
'notes' => $this->notes,
'handled_by' => $this->whenLoaded('handler', fn () => $this->handler ? [
'id' => $this->handler->id,
'name' => $this->handler->name,
] : null),
'handled_at' => $this->handled_at?->toIso8601String(),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
'deleted_at' => $this->deleted_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Call;
use App\Support\TranslatablePayload;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Call
*/
class CallResource 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),
'status' => $this->status->value,
'hours_per_week' => $this->hours_per_week,
'duration_weeks' => $this->duration_weeks,
'seniority' => $this->seniority,
'opens_at' => $this->opens_at?->toDateString(),
'closes_at' => $this->closes_at?->toDateString(),
'project_id' => $this->project_id,
'technology_ids' => $this->whenLoaded('technologies', fn () => $this->technologies->pluck('id')->values()),
'applications_count' => $this->whenCounted('applications'),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
'translations' => $this->when(
in_array('translations', explode(',', (string) $request->query('with')), true),
fn () => TranslatablePayload::all($this->resource, $this->translatable),
),
];
}
}

View File

@@ -118,6 +118,10 @@ tags:
description: What the people the collective worked with said description: What the people the collective worked with said
- name: Talks - name: Talks
description: Talks given elsewhere, by members of the collective description: Talks given elsewhere, by members of the collective
- name: Calls
description: Open calls for contributors
- name: Applications
description: The inbox — what people sent from the public site
- name: Technologies - name: Technologies
description: What the collective uses, and its place on the tech radar description: What the collective uses, and its place on the tech radar
- name: Users - name: Users
@@ -1997,6 +2001,206 @@ paths:
"204": { description: Deleted } "204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" } "403": { $ref: "#/components/responses/Forbidden" }
/v1/calls:
get:
tags: [Calls]
summary: List calls
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
- $ref: "#/components/parameters/PerPage"
- name: status
in: query
description: "`open`, `filled` or `closed`."
schema: { type: string }
- name: project_id
in: query
schema: { type: integer }
- name: search
in: query
description: Matches the title within the active language only.
schema: { type: string }
responses:
"200":
description: Paginated calls
content:
application/json:
schema: { $ref: "#/components/schemas/CallCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [Calls]
summary: Create a call
description: |
`technology_ids` is replaced whole when present.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/CallWrite" }
responses:
"201":
description: Call created
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Call" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/calls/{call}:
parameters:
- name: call
in: path
required: true
schema: { type: integer }
get:
tags: [Calls]
summary: Show a call
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
responses:
"200":
description: The call
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Call" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { description: Not found }
patch:
tags: [Calls]
summary: Update a call
description: Partial update. Translatable fields merge into the existing map.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/CallWrite" }
responses:
"200":
description: Call updated
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Call" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
delete:
tags: [Calls]
summary: Delete a call
responses:
"204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/applications:
get:
tags: [Applications]
summary: List applications
parameters:
- $ref: "#/components/parameters/PerPage"
- name: status
in: query
description: "`new`, `contacted`, `accepted`, `declined` or `spam`. Without it, spam is left out."
schema: { type: string }
- name: kind
in: query
description: "`contribute` or `request`."
schema: { type: string }
- name: call_id
in: query
schema: { type: integer }
- name: search
in: query
description: Matches the name, e-mail or organisation.
schema: { type: string }
- name: trashed
in: query
schema: { type: boolean }
responses:
"200":
description: Paginated applications
content:
application/json:
schema: { $ref: "#/components/schemas/ApplicationCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/applications/{application}:
parameters:
- name: application
in: path
required: true
schema: { type: integer }
get:
tags: [Applications]
summary: Show an application
responses:
"200":
description: The application
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Application" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { description: Not found }
patch:
tags: [Applications]
summary: Handle an application
description: |
Only how it is being handled changes: `status` and the private
`notes`. Moving it to `contacted`, `accepted` or `declined` records who
did it and when; back to `new` clears both.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ApplicationWrite" }
responses:
"200":
description: Application updated
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Application" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
delete:
tags: [Applications]
summary: Soft-delete an application
responses:
"204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/applications/{application}/restore:
post:
tags: [Applications]
summary: Restore a soft-deleted application
parameters:
- name: application
in: path
required: true
schema: { type: integer }
responses:
"200":
description: Application restored
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Application" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/media: /v1/media:
post: post:
tags: [Media] tags: [Media]
@@ -3363,6 +3567,98 @@ components:
links: { $ref: "#/components/schemas/PaginationLinks" } links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" } meta: { $ref: "#/components/schemas/PaginationMeta" }
Call:
type: object
properties:
id: { type: integer }
locale: { type: string }
title: { type: string, nullable: true }
slug: { type: string, nullable: true }
description: { type: string, nullable: true }
what_you_get: { type: string, nullable: true }
status: { type: string, enum: [open, filled, closed] }
hours_per_week: { type: integer, nullable: true }
duration_weeks: { type: integer, nullable: true }
seniority: { type: string, nullable: true, enum: [any, junior, mid, senior] }
opens_at: { type: string, format: date, nullable: true }
closes_at: { type: string, format: date, nullable: true }
project_id: { type: integer, nullable: true }
technology_ids: { type: array, items: { type: integer } }
applications_count: { type: integer }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." }
CallWrite:
type: object
required: [title, slug]
properties:
title: { $ref: "#/components/schemas/TranslatedField" }
slug:
allOf: [{ $ref: "#/components/schemas/TranslatedField" }]
description: Unique per language.
description: { $ref: "#/components/schemas/TranslatedField" }
what_you_get: { $ref: "#/components/schemas/TranslatedField" }
status: { type: string, enum: [open, filled, closed], default: open }
hours_per_week: { type: integer, nullable: true, minimum: 1, maximum: 40 }
duration_weeks: { type: integer, nullable: true, minimum: 1 }
seniority: { type: string, nullable: true, enum: [any, junior, mid, senior] }
opens_at: { type: string, format: date, nullable: true }
closes_at: { type: string, format: date, nullable: true }
project_id: { type: integer, nullable: true }
technology_ids: { type: array, items: { type: integer } }
CallCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Call" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
Application:
type: object
properties:
id: { type: integer }
kind: { type: string, enum: [contribute, request] }
status: { type: string, enum: [new, contacted, accepted, declined, spam] }
call_id: { type: integer, nullable: true }
subject: { type: string, description: "The call's title, or a general enquiry." }
name: { type: string }
email: { type: string, format: email }
organisation: { type: string, nullable: true }
message: { type: string }
hours_per_week: { type: integer, nullable: true }
links: { type: object, additionalProperties: { type: string }, description: "The links the sender gave, keyed as they were sent." }
locale: { type: string, description: The language it was written in. }
notes: { type: string, nullable: true, description: Private to the collective. }
handled_by:
type: object
nullable: true
properties:
id: { type: integer }
name: { type: string }
handled_at: { type: string, format: date-time, 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 }
ApplicationWrite:
type: object
properties:
status: { type: string, enum: [new, contacted, accepted, declined, spam] }
notes: { type: string, nullable: true, maxLength: 10000 }
ApplicationCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Application" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
MediaFor: MediaFor:
type: string type: string
enum: [pages, technologies, projects, members, clients, testimonials] enum: [pages, technologies, projects, members, clients, testimonials]

View File

@@ -10,6 +10,8 @@ use App\Http\Controllers\Api\PublicApi\V1\ProjectController as PublicProjectCont
use App\Http\Controllers\Api\PublicApi\V1\ShowcaseController; use App\Http\Controllers\Api\PublicApi\V1\ShowcaseController;
use App\Http\Controllers\Api\PublicApi\V1\SiteController; use App\Http\Controllers\Api\PublicApi\V1\SiteController;
use App\Http\Controllers\Api\PublicApi\V1\TelemetryController; use App\Http\Controllers\Api\PublicApi\V1\TelemetryController;
use App\Http\Controllers\Api\V1\ApplicationController as AdminApplicationController;
use App\Http\Controllers\Api\V1\CallController;
use App\Http\Controllers\Api\V1\CategoryController; use App\Http\Controllers\Api\V1\CategoryController;
use App\Http\Controllers\Api\V1\ClientController; use App\Http\Controllers\Api\V1\ClientController;
use App\Http\Controllers\Api\V1\MeController; use App\Http\Controllers\Api\V1\MeController;
@@ -192,8 +194,16 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
->name("{$resource}.restore"); ->name("{$resource}.restore");
} }
// Deleted for good, like the panel does: a talk has no bin. // Deleted for good, like the panel does: neither has a bin.
Route::apiResource('talks', TalkController::class); Route::apiResource('talks', TalkController::class);
Route::apiResource('calls', CallController::class);
// The inbox. Submissions arrive from the public site, never from here.
Route::apiResource('applications', AdminApplicationController::class)
->only(['index', 'show', 'update', 'destroy']);
Route::post('applications/{application}/restore', [AdminApplicationController::class, 'restore'])
->withTrashed()
->name('applications.restore');
// Upload only: the file is then referenced from a record by its path. // Upload only: the file is then referenced from a record by its path.
Route::post('media', [MediaController::class, 'store'])->name('media.store'); Route::post('media', [MediaController::class, 'store'])->name('media.store');

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Tests\Feature\Api; namespace Tests\Feature\Api;
use App\Models\Application;
use App\Models\Call;
use App\Models\Category; use App\Models\Category;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
@@ -78,6 +80,8 @@ class AdminApiContractTest extends TestCase
'clients' => ['path' => '/v1/clients'], 'clients' => ['path' => '/v1/clients'],
'testimonials' => ['path' => '/v1/testimonials'], 'testimonials' => ['path' => '/v1/testimonials'],
'talks' => ['path' => '/v1/talks'], 'talks' => ['path' => '/v1/talks'],
'calls' => ['path' => '/v1/calls'],
'applications' => ['path' => '/v1/applications'],
]; ];
} }
@@ -93,6 +97,8 @@ class AdminApiContractTest extends TestCase
$client = Client::factory()->create(); $client = Client::factory()->create();
$testimonial = Testimonial::factory()->create(); $testimonial = Testimonial::factory()->create();
$talk = Talk::factory()->create(); $talk = Talk::factory()->create();
$call = Call::factory()->create();
$application = Application::factory()->for($call)->create();
// One request, one documented path — the URL a real caller hits and // One request, one documented path — the URL a real caller hits and
// the path key that describes it, side by side rather than derived // the path key that describes it, side by side rather than derived
@@ -117,6 +123,10 @@ class AdminApiContractTest extends TestCase
'testimonial' => ["/v1/testimonials/{$testimonial->id}", '/v1/testimonials/{testimonial}'], 'testimonial' => ["/v1/testimonials/{$testimonial->id}", '/v1/testimonials/{testimonial}'],
'talks' => ['/v1/talks', '/v1/talks'], 'talks' => ['/v1/talks', '/v1/talks'],
'talk' => ["/v1/talks/{$talk->id}", '/v1/talks/{talk}'], 'talk' => ["/v1/talks/{$talk->id}", '/v1/talks/{talk}'],
'calls' => ['/v1/calls', '/v1/calls'],
'call' => ["/v1/calls/{$call->id}", '/v1/calls/{call}'],
'applications' => ['/v1/applications', '/v1/applications'],
'application' => ["/v1/applications/{$application->id}", '/v1/applications/{application}'],
'users' => ['/v1/users', '/v1/users'], 'users' => ['/v1/users', '/v1/users'],
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'], 'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
'me' => ['/v1/me', '/v1/me'], 'me' => ['/v1/me', '/v1/me'],

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Enums\ApplicationStatus;
use App\Models\Application;
use App\Models\Call;
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;
/**
* Calls for contributors and the inbox they fill, through the admin API.
*/
class CallsAndInboxApiTest 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_call_is_opened_with_its_stack(): void
{
$this->actingWithRole('editor');
$technology = Technology::factory()->create();
$this->postJson('/api/v1/calls?lang=es', [
'title' => ['es' => 'Backend en Laravel', 'en' => 'Laravel backend'],
'slug' => ['es' => 'backend-laravel', 'en' => 'laravel-backend'],
'hours_per_week' => 4,
'duration_weeks' => 8,
'seniority' => 'mid',
'closes_at' => '2026-12-01',
'technology_ids' => [$technology->id],
])
->assertCreated()
->assertJsonPath('data.title', 'Backend en Laravel')
->assertJsonPath('data.status', 'open')
->assertJsonPath('data.technology_ids', [$technology->id])
->assertJsonPath('data.applications_count', 0);
$this->postJson('/api/v1/calls', [
'title' => ['es' => 'Otra'],
'slug' => ['es' => 'backend-laravel'],
'hours_per_week' => 60,
'seniority' => 'guru',
])
->assertUnprocessable()
->assertJsonValidationErrors(['slug', 'hours_per_week', 'seniority']);
}
#[Test]
public function a_call_is_closed_and_then_deleted(): void
{
$this->actingWithRole('editor');
$call = Call::factory()->create();
$this->patchJson("/api/v1/calls/{$call->id}", ['status' => 'filled'])
->assertOk()
->assertJsonPath('data.status', 'filled');
$this->getJson('/api/v1/calls?status=open')->assertJsonCount(0, 'data');
$this->deleteJson("/api/v1/calls/{$call->id}")->assertNoContent();
$this->assertModelMissing($call);
}
#[Test]
public function the_inbox_leaves_spam_out_unless_asked(): void
{
$this->actingWithRole('editor');
Application::factory()->create(['name' => 'Ada']);
Application::factory()->create(['status' => ApplicationStatus::Spam]);
$this->getJson('/api/v1/applications')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.name', 'Ada')
->assertJsonMissingPath('data.0.ip_hash');
$this->getJson('/api/v1/applications?status=spam')->assertJsonCount(1, 'data');
$this->getJson('/api/v1/applications?search=ada')->assertJsonCount(1, 'data');
}
#[Test]
public function handling_an_application_records_who_did_it(): void
{
$editor = $this->actingWithRole('editor');
$application = Application::factory()->create();
$this->patchJson("/api/v1/applications/{$application->id}", [
'status' => 'contacted',
'notes' => 'Llamada el jueves.',
])
->assertOk()
->assertJsonPath('data.status', 'contacted')
->assertJsonPath('data.notes', 'Llamada el jueves.')
->assertJsonPath('data.handled_by.id', $editor->id);
$this->assertNotNull($application->fresh()->handled_at);
$this->patchJson("/api/v1/applications/{$application->id}", ['status' => 'new'])
->assertJsonPath('data.handled_by', null)
->assertJsonPath('data.handled_at', null);
}
#[Test]
public function what_the_sender_wrote_cannot_be_changed(): void
{
$this->actingWithRole('editor');
$application = Application::factory()->create(['email' => 'real@example.org']);
$this->patchJson("/api/v1/applications/{$application->id}", ['email' => 'fake@example.org', 'status' => 'accepted'])
->assertOk();
$this->assertSame('real@example.org', $application->fresh()->email);
}
#[Test]
public function nobody_creates_an_application_here(): void
{
$this->actingWithRole('admin');
$this->postJson('/api/v1/applications', ['name' => 'X'])->assertMethodNotAllowed();
}
#[Test]
public function a_writer_cannot_read_the_inbox(): void
{
$this->actingWithRole('writer');
$application = Application::factory()->create();
$this->getJson('/api/v1/applications')->assertForbidden();
$this->getJson("/api/v1/applications/{$application->id}")->assertForbidden();
// Calls are theirs to read, like on the panel.
$this->getJson('/api/v1/calls')->assertOk();
}
#[Test]
public function a_deleted_application_can_be_brought_back(): void
{
$this->actingWithRole('editor');
$application = Application::factory()->create();
$this->deleteJson("/api/v1/applications/{$application->id}")->assertNoContent();
$this->assertSoftDeleted($application);
$this->postJson("/api/v1/applications/{$application->id}/restore")->assertOk();
$this->assertNotSoftDeleted($application);
}
}