diff --git a/app/Http/Controllers/Api/V1/InvitationController.php b/app/Http/Controllers/Api/V1/InvitationController.php index 397decf0..94875cbc 100644 --- a/app/Http/Controllers/Api/V1/InvitationController.php +++ b/app/Http/Controllers/Api/V1/InvitationController.php @@ -12,7 +12,9 @@ use App\Models\Organization; use App\Models\OrganizationInvitation; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\Mail; use Laravel\Jetstream\Contracts\InvitesTeamMembers; +use Laravel\Jetstream\Mail\TeamInvitation; class InvitationController extends Controller { @@ -64,6 +66,22 @@ class InvitationController extends Controller return response()->json(null, 204); } + /** + * Resend email for a pending invitation + * + * @throws AuthorizationException + * + * @operationId resendInvitationEmail + */ + public function resend(Organization $organization, OrganizationInvitation $invitation): JsonResponse + { + $this->checkPermission($organization, 'invitations:resend', $invitation); + + Mail::to($invitation->email)->send(new TeamInvitation($invitation)); + + return response()->json(null, 204); + } + /** * Remove a pending invitation * diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index bc787977..f975e58f 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -71,6 +71,7 @@ class JetstreamServiceProvider extends ServiceProvider 'project-members:update', 'project-members:delete', 'tasks:view', + 'tasks:view:all', 'tasks:create', 'tasks:update', 'tasks:delete', @@ -95,6 +96,7 @@ class JetstreamServiceProvider extends ServiceProvider 'import', 'invitations:view', 'invitations:create', + 'invitations:resend', 'invitations:remove', 'members:view', 'members:invite-placeholder', @@ -114,6 +116,7 @@ class JetstreamServiceProvider extends ServiceProvider 'project-members:update', 'project-members:delete', 'tasks:view', + 'tasks:view:all', 'tasks:create', 'tasks:update', 'tasks:delete', @@ -136,6 +139,10 @@ class JetstreamServiceProvider extends ServiceProvider 'organizations:view', 'organizations:update', 'import', + 'invitations:view', + 'invitations:create', + 'invitations:resend', + 'invitations:remove', 'members:view', 'members:invite-placeholder', ])->description('Administrator users can perform any action.'); @@ -151,6 +158,7 @@ class JetstreamServiceProvider extends ServiceProvider 'project-members:update', 'project-members:delete', 'tasks:view', + 'tasks:view:all', 'tasks:create', 'tasks:update', 'tasks:delete', @@ -171,6 +179,7 @@ class JetstreamServiceProvider extends ServiceProvider 'clients:update', 'clients:delete', 'organizations:view', + 'invitations:view', 'members:view', ])->description('Managers have the ability to read, create, and update their own time entries as well as those of their team.'); diff --git a/routes/api.php b/routes/api.php index 1db37f7a..0c9494ba 100644 --- a/routes/api.php +++ b/routes/api.php @@ -49,6 +49,7 @@ Route::middleware([ Route::name('invitations.')->group(static function () { Route::get('/organizations/{organization}/invitations', [InvitationController::class, 'index'])->name('index'); Route::post('/organizations/{organization}/invitations', [InvitationController::class, 'store'])->name('store'); + Route::post('/organizations/{organization}/invitations/{invitation}/resend', [InvitationController::class, 'resend'])->name('resend'); Route::delete('/organizations/{organization}/invitations/{invitation}', [InvitationController::class, 'destroy'])->name('destroy'); }); diff --git a/tests/TestCase.php b/tests/TestCase.php index e47e0a72..0aeb8c23 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,6 +6,7 @@ namespace Tests; use Illuminate\Database\Eloquent\Collection; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; +use Illuminate\Support\Facades\Mail; use TiMacDonald\Log\LogFake; abstract class TestCase extends BaseTestCase @@ -15,6 +16,7 @@ abstract class TestCase extends BaseTestCase protected function setUp(): void { parent::setUp(); + Mail::fake(); LogFake::bind(); } diff --git a/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php b/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php index bd8fe78f..1afb963b 100644 --- a/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace Tests\Unit\Endpoint\Api\V1; use App\Models\OrganizationInvitation; +use Illuminate\Support\Facades\Mail; +use Laravel\Jetstream\Mail\TeamInvitation; use Laravel\Passport\Passport; class InvitationEndpointTest extends ApiEndpointTestAbstract @@ -77,6 +79,65 @@ class InvitationEndpointTest extends ApiEndpointTestAbstract $this->assertEquals('employee', $invitation->role); } + public function test_resend_fails_if_user_has_no_permission_to_resend_the_invitation(): void + { + // Arrange + $data = $this->createUserWithPermission([ + ]); + Passport::actingAs($data->user); + $invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create(); + + // Act + $response = $this->postJson(route('api.v1.invitations.resend', [ + $data->organization->getKey(), + $invitation->getKey(), + ])); + + // Assert + Mail::assertNothingSent(); + Mail::assertNothingQueued(); + $response->assertStatus(403); + } + + public function test_resend_fails_if_invitation_belongs_to_different_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'invitations:resend', + ]); + Passport::actingAs($data->user); + $invitation = OrganizationInvitation::factory()->create(); + + // Act + $response = $this->postJson(route('api.v1.invitations.resend', [$data->organization->getKey(), $invitation->getKey()])); + + // Assert + Mail::assertNothingSent(); + Mail::assertNothingQueued(); + $response->assertStatus(403); + } + + public function test_resend_resends_invitation_email(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'invitations:resend', + ]); + Passport::actingAs($data->user); + $invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create(); + + // Act + $response = $this->postJson(route('api.v1.invitations.resend', [ + $data->organization->getKey(), + $invitation->getKey(), + ])); + + // Assert + Mail::assertSent(fn (TeamInvitation $mail): bool => $mail->invitation->is($invitation)); + Mail::assertNothingQueued(); + $response->assertStatus(204); + } + public function test_delete_fails_if_user_has_no_permission_to_remove_invitations(): void { // Arrange