Compare commits

..

3 commits

Author SHA1 Message Date
Claude
f6f22cbe34
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>
2026-03-24 13:51:07 +00:00
Claude
069fa0235d
docs: fix incorrect namespace in README.md usage examples
Change Core\Mod\Tenant\Services to Core\Tenant\Services to match
the actual namespace used in source files.

Fixes #28

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:45:46 +00:00
Claude
eb90702feb
chore: add PHPStan/Larastan to dev dependencies
Add larastan/larastan ^3.0 to require-dev and create phpstan.neon
config at level 5 covering all source directories. Adds a
`composer analyse` script shortcut.

Fixes #26

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:45:36 +00:00
5 changed files with 247 additions and 3 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

@ -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);

View file

@ -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
View 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

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