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

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