feat: add bulk workspace invitation support (inviteMany)

Add Workspace::inviteMany() method that sends multiple invitations in
one call. Handles duplicates gracefully by skipping already-invited
members and deduplicating input emails (case-insensitive). Returns a
collection of results with status per email.

Fixes #36

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude 2026-03-24 13:51:07 +00:00
parent 069fa0235d
commit f6f22cbe34
No known key found for this signature in database
GPG key ID: AF404715446AEB41
2 changed files with 209 additions and 0 deletions

View file

@ -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<string> $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<int, array{email: string, status: string, invitation: WorkspaceInvitation|null}>
*/
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.
*

View file

@ -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']);
}
}