diff --git a/README.md b/README.md index 5e306ae..f54f9a3 100644 --- a/README.md +++ b/README.md @@ -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) ``` diff --git a/app/Http/Controllers/Api/V1/ApplicationController.php b/app/Http/Controllers/Api/V1/ApplicationController.php new file mode 100644 index 0000000..50b603a --- /dev/null +++ b/app/Http/Controllers/Api/V1/ApplicationController.php @@ -0,0 +1,104 @@ +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)); + } +} diff --git a/app/Http/Controllers/Api/V1/CallController.php b/app/Http/Controllers/Api/V1/CallController.php new file mode 100644 index 0000000..e47f7ea --- /dev/null +++ b/app/Http/Controllers/Api/V1/CallController.php @@ -0,0 +1,95 @@ +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'); + } +} diff --git a/app/Http/Requests/Api/V1/ApplicationRequest.php b/app/Http/Requests/Api/V1/ApplicationRequest.php new file mode 100644 index 0000000..b04fae1 --- /dev/null +++ b/app/Http/Requests/Api/V1/ApplicationRequest.php @@ -0,0 +1,32 @@ + + */ + public function rules(): array + { + return [ + 'status' => ['sometimes', Rule::enum(ApplicationStatus::class)], + 'notes' => ['sometimes', 'nullable', 'string', 'max:10000'], + ]; + } +} diff --git a/app/Http/Requests/Api/V1/CallRequest.php b/app/Http/Requests/Api/V1/CallRequest.php new file mode 100644 index 0000000..3396195 --- /dev/null +++ b/app/Http/Requests/Api/V1/CallRequest.php @@ -0,0 +1,58 @@ + + */ + 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')], + ]; + } +} diff --git a/app/Http/Resources/ApplicationResource.php b/app/Http/Resources/ApplicationResource.php new file mode 100644 index 0000000..bcf888d --- /dev/null +++ b/app/Http/Resources/ApplicationResource.php @@ -0,0 +1,49 @@ + + */ + 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(), + ]; + } +} diff --git a/app/Http/Resources/CallResource.php b/app/Http/Resources/CallResource.php new file mode 100644 index 0000000..866fa30 --- /dev/null +++ b/app/Http/Resources/CallResource.php @@ -0,0 +1,49 @@ + + */ + 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), + ), + ]; + } +} diff --git a/public/openapi.yaml b/public/openapi.yaml index 358413d..6cc81d6 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -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] diff --git a/routes/api.php b/routes/api.php index 52df4c9..d454e25 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); diff --git a/tests/Feature/Api/AdminApiContractTest.php b/tests/Feature/Api/AdminApiContractTest.php index 7a8f20a..c952e68 100644 --- a/tests/Feature/Api/AdminApiContractTest.php +++ b/tests/Feature/Api/AdminApiContractTest.php @@ -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'], diff --git a/tests/Feature/Api/CallsAndInboxApiTest.php b/tests/Feature/Api/CallsAndInboxApiTest.php new file mode 100644 index 0000000..24d866a --- /dev/null +++ b/tests/Feature/Api/CallsAndInboxApiTest.php @@ -0,0 +1,180 @@ +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); + } +}