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,
|
/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)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
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
|
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]
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
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