diff --git a/Models/Workspace.php b/Models/Workspace.php index 490bc64..4e0e367 100644 --- a/Models/Workspace.php +++ b/Models/Workspace.php @@ -736,6 +736,52 @@ class Workspace extends Model return $invitation; } + /** + * Invite multiple users to this workspace by email. + * + * Sends invitations to all provided emails, skipping any that already + * belong to this workspace as members. Duplicate emails in the input + * array are deduplicated before processing. + * + * @param array $emails The email addresses to invite + * @param string $role The role to assign (owner, admin, member) + * @param User|null $invitedBy The user sending the invitations + * @param int $expiresInDays Number of days until invitations expire + * @return Collection + */ + public function inviteMany(array $emails, string $role = 'member', ?User $invitedBy = null, int $expiresInDays = 7): Collection + { + // Deduplicate and normalise emails + $emails = collect($emails) + ->map(fn (string $email) => strtolower(trim($email))) + ->unique() + ->values(); + + // Get emails of existing workspace members to skip them + $existingMemberEmails = $this->users() + ->pluck('email') + ->map(fn (string $email) => strtolower($email)); + + return $emails->map(function (string $email) use ($role, $invitedBy, $expiresInDays, $existingMemberEmails): array { + // Skip users who are already workspace members + if ($existingMemberEmails->contains($email)) { + return [ + 'email' => $email, + 'status' => 'already_member', + 'invitation' => null, + ]; + } + + $invitation = $this->invite($email, $role, $invitedBy, $expiresInDays); + + return [ + 'email' => $email, + 'status' => 'invited', + 'invitation' => $invitation, + ]; + }); + } + /** * Accept an invitation to this workspace using a token. * diff --git a/tests/Feature/WorkspaceInvitationTest.php b/tests/Feature/WorkspaceInvitationTest.php index 8025647..a0d1b14 100644 --- a/tests/Feature/WorkspaceInvitationTest.php +++ b/tests/Feature/WorkspaceInvitationTest.php @@ -251,4 +251,167 @@ class WorkspaceInvitationTest extends TestCase $this->assertEquals(1, WorkspaceInvitation::expired()->count()); $this->assertEquals(1, WorkspaceInvitation::accepted()->count()); } + + // ───────────────────────────────────────────────────────────────────────── + // Bulk Invitation Tests (inviteMany) + // ───────────────────────────────────────────────────────────────────────── + + public function test_invite_many_sends_multiple_invitations(): void + { + Notification::fake(); + + $owner = User::factory()->create(); + $workspace = Workspace::factory()->create(); + $workspace->users()->attach($owner->id, ['role' => 'owner']); + + $emails = [ + 'alice@example.com', + 'bob@example.com', + 'charlie@example.com', + ]; + + $results = $workspace->inviteMany($emails, 'member', $owner); + + $this->assertCount(3, $results); + + foreach ($results as $result) { + $this->assertEquals('invited', $result['status']); + $this->assertInstanceOf(WorkspaceInvitation::class, $result['invitation']); + } + + $this->assertEquals(3, $workspace->invitations()->count()); + + Notification::assertSentTimes(WorkspaceInvitationNotification::class, 3); + } + + public function test_invite_many_skips_existing_members(): void + { + Notification::fake(); + + $owner = User::factory()->create(); + $existingMember = User::factory()->create(['email' => 'existing@example.com']); + $workspace = Workspace::factory()->create(); + $workspace->users()->attach($owner->id, ['role' => 'owner']); + $workspace->users()->attach($existingMember->id, ['role' => 'member']); + + $emails = [ + 'existing@example.com', + 'newuser@example.com', + ]; + + $results = $workspace->inviteMany($emails, 'member', $owner); + + $this->assertCount(2, $results); + + // First result should be skipped (already a member) + $this->assertEquals('existing@example.com', $results[0]['email']); + $this->assertEquals('already_member', $results[0]['status']); + $this->assertNull($results[0]['invitation']); + + // Second result should be invited + $this->assertEquals('newuser@example.com', $results[1]['email']); + $this->assertEquals('invited', $results[1]['status']); + $this->assertInstanceOf(WorkspaceInvitation::class, $results[1]['invitation']); + + // Only one invitation should be created + $this->assertEquals(1, $workspace->invitations()->count()); + + Notification::assertSentTimes(WorkspaceInvitationNotification::class, 1); + } + + public function test_invite_many_deduplicates_emails(): void + { + Notification::fake(); + + $workspace = Workspace::factory()->create(); + + $emails = [ + 'duplicate@example.com', + 'DUPLICATE@example.com', + ' duplicate@example.com ', + 'unique@example.com', + ]; + + $results = $workspace->inviteMany($emails); + + // Should deduplicate to 2 unique emails + $this->assertCount(2, $results); + $this->assertEquals(2, $workspace->invitations()->count()); + + Notification::assertSentTimes(WorkspaceInvitationNotification::class, 2); + } + + public function test_invite_many_with_empty_array(): void + { + Notification::fake(); + + $workspace = Workspace::factory()->create(); + + $results = $workspace->inviteMany([]); + + $this->assertCount(0, $results); + $this->assertEquals(0, $workspace->invitations()->count()); + + Notification::assertNothingSent(); + } + + public function test_invite_many_assigns_correct_role(): void + { + Notification::fake(); + + $workspace = Workspace::factory()->create(); + + $emails = ['admin1@example.com', 'admin2@example.com']; + $results = $workspace->inviteMany($emails, 'admin'); + + foreach ($results as $result) { + $this->assertEquals('admin', $result['invitation']->role); + } + } + + public function test_invite_many_handles_resend_for_pending_invitations(): void + { + Notification::fake(); + + $owner = User::factory()->create(); + $workspace = Workspace::factory()->create(); + $workspace->users()->attach($owner->id, ['role' => 'owner']); + + // First invitation as member + $workspace->invite('resend@example.com', 'member', $owner); + + // Bulk invite includes the same email with a different role + $results = $workspace->inviteMany( + ['resend@example.com', 'fresh@example.com'], + 'admin', + $owner + ); + + $this->assertCount(2, $results); + + // Should still only have 2 invitations (1 updated + 1 new) + $this->assertEquals(2, $workspace->invitations()->count()); + + // The resent invitation should have the updated role + $resendResult = $results->firstWhere('email', 'resend@example.com'); + $this->assertEquals('invited', $resendResult['status']); + $this->assertEquals('admin', $resendResult['invitation']->role); + } + + public function test_invite_many_skips_existing_member_case_insensitive(): void + { + Notification::fake(); + + $owner = User::factory()->create(); + $existingMember = User::factory()->create(['email' => 'Member@Example.COM']); + $workspace = Workspace::factory()->create(); + $workspace->users()->attach($owner->id, ['role' => 'owner']); + $workspace->users()->attach($existingMember->id, ['role' => 'member']); + + $results = $workspace->inviteMany(['member@example.com']); + + $this->assertCount(1, $results); + $this->assertEquals('already_member', $results[0]['status']); + $this->assertNull($results[0]['invitation']); + } }