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/README.md b/README.md index 3a71b6e..56e578f 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,8 @@ php artisan migrate ### Workspace Management ```php -use Core\Mod\Tenant\Services\WorkspaceManager; -use Core\Mod\Tenant\Services\WorkspaceService; +use Core\Tenant\Services\WorkspaceManager; +use Core\Tenant\Services\WorkspaceService; // Get current workspace $workspace = app(WorkspaceManager::class)->current(); @@ -57,7 +57,7 @@ $workspace = app(WorkspaceService::class)->create([ ### Entitlements ```php -use Core\Mod\Tenant\Services\EntitlementService; +use Core\Tenant\Services\EntitlementService; $entitlements = app(EntitlementService::class); diff --git a/composer.json b/composer.json index 159889a..1d3acfb 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "lthn/php": "*" }, "require-dev": { + "larastan/larastan": "^3.0", "laravel/pint": "^1.18", "orchestra/testbench": "^9.0|^10.0", "pestphp/pest": "^3.0" @@ -35,6 +36,7 @@ } }, "scripts": { + "analyse": "phpstan analyse", "lint": "pint", "test": "pest" }, diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..bca4599 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,33 @@ +includes: + - vendor/larastan/larastan/extension.neon + +parameters: + level: 5 + + paths: + - Boot.php + - Concerns + - Console + - Contracts + - Controllers + - Database + - Enums + - Events + - Exceptions + - Features + - Jobs + - Listeners + - Mail + - Middleware + - Models + - Notifications + - Routes + - Rules + - Scopes + - Services + - View + + excludePaths: + - vendor + + checkMissingIterableValueType: false 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']); + } }