Manage calls and the inbox through the API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1273,6 +1273,12 @@ GET /api/v1/roles/catalogue catálogo de permisos agrupado
|
||||
/api/v1/testimonials CRUD + restore; filtros: is_public, client_id,
|
||||
project_id, service_id, search
|
||||
/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)
|
||||
```
|
||||
|
||||
|
||||
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),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,10 @@ tags:
|
||||
description: What the people the collective worked with said
|
||||
- name: Talks
|
||||
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
|
||||
description: What the collective uses, and its place on the tech radar
|
||||
- name: Users
|
||||
@@ -1997,6 +2001,206 @@ paths:
|
||||
"204": { description: Deleted }
|
||||
"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:
|
||||
post:
|
||||
tags: [Media]
|
||||
@@ -3363,6 +3567,98 @@ components:
|
||||
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||
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:
|
||||
type: string
|
||||
enum: [pages, technologies, projects, members, clients, testimonials]
|
||||
|
||||
@@ -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\SiteController;
|
||||
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\ClientController;
|
||||
use App\Http\Controllers\Api\V1\MeController;
|
||||
@@ -192,8 +194,16 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
->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('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.
|
||||
Route::post('media', [MediaController::class, 'store'])->name('media.store');
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Call;
|
||||
use App\Models\Category;
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
@@ -78,6 +80,8 @@ class AdminApiContractTest extends TestCase
|
||||
'clients' => ['path' => '/v1/clients'],
|
||||
'testimonials' => ['path' => '/v1/testimonials'],
|
||||
'talks' => ['path' => '/v1/talks'],
|
||||
'calls' => ['path' => '/v1/calls'],
|
||||
'applications' => ['path' => '/v1/applications'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -93,6 +97,8 @@ class AdminApiContractTest extends TestCase
|
||||
$client = Client::factory()->create();
|
||||
$testimonial = Testimonial::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
|
||||
// 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}'],
|
||||
'talks' => ['/v1/talks', '/v1/talks'],
|
||||
'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'],
|
||||
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
|
||||
'me' => ['/v1/me', '/v1/me'],
|
||||
|
||||
180
tests/Feature/Api/CallsAndInboxApiTest.php
Normal file
180
tests/Feature/Api/CallsAndInboxApiTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user