Compare commits
3 commits
dev
...
feat/bulk-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6f22cbe34 | ||
|
|
069fa0235d | ||
|
|
eb90702feb |
5 changed files with 247 additions and 3 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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
33
phpstan.neon
Normal file
33
phpstan.neon
Normal file
|
|
@ -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
|
||||
|
|
@ -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