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:
parent
069fa0235d
commit
f6f22cbe34
2 changed files with 209 additions and 0 deletions
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue