Manage calls and the inbox through the API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
104
app/Http/Controllers/Api/V1/ApplicationController.php
Normal file
104
app/Http/Controllers/Api/V1/ApplicationController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
95
app/Http/Controllers/Api/V1/CallController.php
Normal file
95
app/Http/Controllers/Api/V1/CallController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/Api/V1/ApplicationRequest.php
Normal file
32
app/Http/Requests/Api/V1/ApplicationRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
58
app/Http/Requests/Api/V1/CallRequest.php
Normal file
58
app/Http/Requests/Api/V1/CallRequest.php
Normal 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')],
|
||||
];
|
||||
}
|
||||
}
|
||||
49
app/Http/Resources/ApplicationResource.php
Normal file
49
app/Http/Resources/ApplicationResource.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
49
app/Http/Resources/CallResource.php
Normal file
49
app/Http/Resources/CallResource.php
Normal 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),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user