diff --git a/app/Actions/Jetstream/CreateOrganization.php b/app/Actions/Jetstream/CreateOrganization.php index d81760e6..8ef42564 100644 --- a/app/Actions/Jetstream/CreateOrganization.php +++ b/app/Actions/Jetstream/CreateOrganization.php @@ -9,6 +9,7 @@ use App\Models\Organization; use App\Models\User; use App\Service\IpLookup\IpLookupServiceContract; use App\Service\OrganizationService; +use App\Service\UserService; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Validator; @@ -50,10 +51,8 @@ class CreateOrganization implements CreatesTeams $currency ); - $user->switchTeam($organization); + app(UserService::class)->switchCurrentOrganization($user, $organization); - // Note: The refresh is necessary for currently unknown reasons. Do not remove it. - $organization = $organization->refresh(); AfterCreateOrganization::dispatch($organization); return $organization; diff --git a/app/Console/Commands/Admin/UserCreateCommand.php b/app/Console/Commands/Admin/UserCreateCommand.php index 65b471fe..43670d09 100644 --- a/app/Console/Commands/Admin/UserCreateCommand.php +++ b/app/Console/Commands/Admin/UserCreateCommand.php @@ -69,7 +69,7 @@ class UserCreateCommand extends Command ); }); /** @var Organization|null $organization */ - $organization = $user->ownedTeams->first(); + $organization = $user->ownedOrganizations->first(); if ($organization === null) { throw new LogicException('User does not have an organization'); } diff --git a/app/Filament/Resources/OrganizationResource/RelationManagers/InvitationsRelationManager.php b/app/Filament/Resources/OrganizationResource/RelationManagers/InvitationsRelationManager.php index be5e7e5d..79108b55 100644 --- a/app/Filament/Resources/OrganizationResource/RelationManagers/InvitationsRelationManager.php +++ b/app/Filament/Resources/OrganizationResource/RelationManagers/InvitationsRelationManager.php @@ -21,7 +21,7 @@ use Illuminate\Validation\Rule; class InvitationsRelationManager extends RelationManager { - protected static string $relationship = 'teamInvitations'; + protected static string $relationship = 'organizationInvitations'; protected static ?string $title = 'Invitations'; diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index 31a04888..cd96ed52 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -12,6 +12,7 @@ use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelat use App\Models\User; use App\Service\DeletionService; use App\Service\TimezoneService; +use App\Service\UserService; use Brick\Money\ISOCurrencyProvider; use Exception; use Filament\Forms; @@ -179,7 +180,7 @@ class UserResource extends Resource ]) ->actions([ Impersonate::make()->before(function (User $record): void { - if ($record->currentTeam === null) { + if ($record->currentOrganization === null) { $organization = $record->organizations()->where('personal_team', '=', true)->first(); if ($organization === null) { $organization = $record->organizations()->first(); @@ -187,8 +188,7 @@ class UserResource extends Resource if ($organization === null) { throw new Exception('User has no organization'); } - $record->currentTeam()->associate($organization); - $record->save(); + app(UserService::class)->switchCurrentOrganization($record, $organization); } }), Tables\Actions\EditAction::make(), diff --git a/app/Filament/Resources/UserResource/RelationManagers/OwnedOrganizationsRelationManager.php b/app/Filament/Resources/UserResource/RelationManagers/OwnedOrganizationsRelationManager.php index 07c593c2..c348c3dc 100644 --- a/app/Filament/Resources/UserResource/RelationManagers/OwnedOrganizationsRelationManager.php +++ b/app/Filament/Resources/UserResource/RelationManagers/OwnedOrganizationsRelationManager.php @@ -16,7 +16,7 @@ class OwnedOrganizationsRelationManager extends RelationManager { protected static ?string $title = 'Owned Organizations'; - protected static string $relationship = 'ownedTeams'; + protected static string $relationship = 'ownedOrganizations'; public function form(Form $form): Form { diff --git a/app/Http/Controllers/Api/V1/InvitationController.php b/app/Http/Controllers/Api/V1/InvitationController.php index 4e7a0dcd..8ef32854 100644 --- a/app/Http/Controllers/Api/V1/InvitationController.php +++ b/app/Http/Controllers/Api/V1/InvitationController.php @@ -40,7 +40,7 @@ class InvitationController extends Controller { $this->checkPermission($organization, 'invitations:view'); - $invitations = $organization->teamInvitations() + $invitations = $organization->organizationInvitations() ->orderBy('created_at', 'desc') ->paginate(config('app.pagination_per_page_default')); diff --git a/app/Http/Controllers/Api/V1/OrganizationController.php b/app/Http/Controllers/Api/V1/OrganizationController.php index 925fc728..acf58e49 100644 --- a/app/Http/Controllers/Api/V1/OrganizationController.php +++ b/app/Http/Controllers/Api/V1/OrganizationController.php @@ -14,6 +14,7 @@ use App\Service\BillableRateService; use App\Service\DeletionService; use App\Service\IpLookup\IpLookupServiceContract; use App\Service\OrganizationService; +use App\Service\UserService; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; @@ -106,10 +107,8 @@ class OrganizationController extends Controller $currency ); - $user->switchTeam($organization); + app(UserService::class)->switchCurrentOrganization($user, $organization); - // Note: The refresh is necessary for currently unknown reasons. Do not remove it. - $organization = $organization->refresh(); AfterCreateOrganization::dispatch($organization); return new OrganizationResource($organization, true); diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 96fda020..bba77280 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -59,7 +59,7 @@ class Controller extends BaseController protected function currentOrganization(): Organization { $user = $this->user(); - $organization = $user->currentTeam; + $organization = $user->currentOrganization; if ($organization === null) { $organization = $user->organizations()->first(); } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 0baae6f4..a27bacf8 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -46,7 +46,7 @@ class HandleInertiaRequests extends Middleware /** @var BillingContract $billing */ $billing = app(BillingContract::class); - $currentOrganization = $request->user()?->currentTeam; + $currentOrganization = $request->user()?->currentOrganization; return array_merge(parent::share($request), [ 'has_billing_extension' => $hasBilling, diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 2981d2ea..e0a67fb7 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -43,7 +43,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract; * @property Carbon|null $updated_at * @property Collection $users * @property Collection $realUsers - * @property-read Collection $teamInvitations + * @property-read Collection $organizationInvitations * @property Member $membership * @property NumberFormat $number_format * @property CurrencyFormat $currency_format @@ -51,7 +51,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract; * @property IntervalFormat $interval_format * @property TimeFormat $time_format * - * @method HasMany teamInvitations() * @method static OrganizationFactory factory() */ class Organization extends JetstreamTeam implements AuditableContract @@ -111,23 +110,6 @@ class Organization extends JetstreamTeam implements AuditableContract protected $attributes = [ ]; - /** - * Get all the non-placeholder users of the organization including its owner. - * - * @return Collection - */ - public function allRealUsers(): Collection - { - return $this->realUsers->merge([$this->owner]); - } - - public function hasRealUserWithEmail(string $email): bool - { - return $this->allRealUsers()->contains(function (User $user) use ($email): bool { - return $user->email === $email; - }); - } - /** * Get all the users that belong to the team. * @@ -172,6 +154,14 @@ class Organization extends JetstreamTeam implements AuditableContract ->where('is_placeholder', false); } + /** + * @return HasMany + */ + public function organizationInvitations(): HasMany + { + return $this->hasMany(OrganizationInvitation::class, 'organization_id'); + } + /** * This method prevents an unhandled exception when the ID is not a UUID. * Normally this can be fixed with a route pattern, but Jetstream does not use route model binding. diff --git a/app/Models/User.php b/app/Models/User.php index 2b0f0bfe..9704db93 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Enums\Role; use App\Enums\Weekday; use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\HasUuids; @@ -52,6 +53,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract; * @property Carbon|null $updated_at * @property string|null $current_team_id * @property Collection $organizations + * @property Collection $ownedOrganizations * @property Collection $timeEntries * @property Member $membership * @@ -131,16 +133,39 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M { return Attribute::get(function (): string { return $this->profile_photo_path - ? Storage::disk($this->profilePhotoDisk())->url($this->profile_photo_path) + ? Storage::disk(config('filesystems.public'))->url($this->profile_photo_path) : $this->defaultProfilePhotoUrl(); }); } + /** + * Get the default profile photo URL if no profile photo has been uploaded. + */ + protected function defaultProfilePhotoUrl(): string + { + $name = trim(collect(explode(' ', $this->name))->map(function ($segment) { + return mb_substr($segment, 0, 1); + })->join(' ')); + + return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF'; + } + public function canAccessPanel(Panel $panel): bool { return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail(); } + public function isMemberOfOrganization(Organization $organization): bool + { + if ($this->relationLoaded('organizations')) { + return $this->organizations->contains(function (Organization $o) use ($organization): bool { + return $o->getKey() === $organization->getKey(); + }); + } + + return $this->organizations()->whereKey($organization->getKey())->exists(); + } + public function canBeImpersonated(): bool { return $this->is_placeholder === false; @@ -161,6 +186,14 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M ->as('membership'); } + /** + * @return BelongsToMany + */ + public function ownedOrganizations(): BelongsToMany + { + return $this->organizations()->wherePivot('role', Role::Owner->value); + } + /** * @return HasMany */ @@ -215,12 +248,8 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M */ public function scopeBelongsToOrganization(Builder $builder, Organization $organization): Builder { - return $builder->where(function (Builder $builder) use ($organization): Builder { - return $builder->whereHas('organizations', function (Builder $query) use ($organization): void { - $query->whereKey($organization->getKey()); - })->orWhereHas('ownedTeams', function (Builder $query) use ($organization): void { - $query->whereKey($organization->getKey()); - }); + return $builder->whereHas('organizations', function (Builder $query) use ($organization): void { + $query->whereKey($organization->getKey()); }); } } diff --git a/app/Policies/OrganizationPolicy.php b/app/Policies/OrganizationPolicy.php index 5658d6b7..5395d1f7 100644 --- a/app/Policies/OrganizationPolicy.php +++ b/app/Policies/OrganizationPolicy.php @@ -35,7 +35,7 @@ class OrganizationPolicy return true; } - return $user->belongsToTeam($organization); + return $user->isMemberOfOrganization($organization); } /** @@ -97,6 +97,6 @@ class OrganizationPolicy return true; } - return $user->ownsTeam($organization); + return app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete'); } } diff --git a/app/Service/DeletionService.php b/app/Service/DeletionService.php index 1ebefaf5..2cee272f 100644 --- a/app/Service/DeletionService.php +++ b/app/Service/DeletionService.php @@ -173,7 +173,7 @@ class DeletionService $user->authCodes()->delete(); // Note: Since the deletion of the profile photo is not reversible via a database rollback this needs to be done last - $user->deleteProfilePhoto(); + $this->userService->deleteProfilePhoto($user); $user->delete(); diff --git a/app/Service/MemberService.php b/app/Service/MemberService.php index 0e6e2dbc..a3fb079f 100644 --- a/app/Service/MemberService.php +++ b/app/Service/MemberService.php @@ -97,7 +97,7 @@ class MemberService $isPlaceholder = $user->is_placeholder; if (! $isPlaceholder && $user->current_team_id === $member->organization_id) { - $user->currentTeam()->disassociate(); + $user->currentOrganization()->disassociate(); $user->save(); } @@ -216,7 +216,7 @@ class MemberService { $user = $member->user; if ($user->current_team_id === $member->organization_id) { - $user->currentTeam()->disassociate(); + $user->currentOrganization()->disassociate(); $user->save(); } diff --git a/app/Service/PermissionStore.php b/app/Service/PermissionStore.php index 74000f95..32072868 100644 --- a/app/Service/PermissionStore.php +++ b/app/Service/PermissionStore.php @@ -291,7 +291,7 @@ class PermissionStore public function userHas(Organization $organization, User $user, string $permission): bool { if (! isset($this->permissionCache[$user->getKey().'|'.$organization->getKey()])) { - if (! $user->belongsToTeam($organization)) { + if (! $user->isMemberOfOrganization($organization)) { return false; } @@ -309,7 +309,7 @@ class PermissionStore */ private function getPermissionsByUser(Organization $organization, User $user): array { - if (! $user->belongsToTeam($organization)) { + if (! $user->isMemberOfOrganization($organization)) { return []; } diff --git a/app/Service/UserService.php b/app/Service/UserService.php index f2765777..bf8b1261 100644 --- a/app/Service/UserService.php +++ b/app/Service/UserService.php @@ -19,6 +19,7 @@ use App\Models\TimeEntry; use App\Models\User; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Storage; class UserService { @@ -61,7 +62,6 @@ class UserService $intervalFormat, $timeFormat, ); - $user->ownedTeams()->save($organization); } return $user; @@ -103,13 +103,17 @@ class UserService true ); - // Set the organization as the user's current organization - $user->currentOrganization()->associate($organization); - $user->save(); + $this->switchCurrentOrganization($user, $organization); AfterCreateOrganization::dispatch($organization); } + public function switchCurrentOrganization(User $user, Organization $organization): void + { + $user->currentOrganization()->associate($organization); + $user->save(); + } + public function getOrganizationNameForUserName(string $username): string { return explode(' ', $username, 2)[0]."'s Organization"; @@ -157,4 +161,16 @@ class UserService $oldOwner->save(); } } + + public function deleteProfilePhoto(User $user): void + { + if ($user->profile_photo_path === null) { + return; + } + + Storage::disk(config('filesystems.public'))->delete($user->profile_photo_path); + + $user->profile_photo_path = null; + $user->save(); + } } diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 8ff220bd..e7b9b38c 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -120,7 +120,7 @@ class UserFactory extends Factory $organization->owner()->associate($user); $organization->users()->attach($user, ['role' => Role::Owner->value]); - $user->currentTeam()->associate($organization); + $user->currentOrganization()->associate($organization); $user->save(); }); } diff --git a/tests/Feature/CreateOrganizationTest.php b/tests/Feature/CreateOrganizationTest.php index 31b1241c..ac50f0eb 100644 --- a/tests/Feature/CreateOrganizationTest.php +++ b/tests/Feature/CreateOrganizationTest.php @@ -34,10 +34,10 @@ class CreateOrganizationTest extends TestCase // Assert $response->assertStatus(302); /** @var Organization|null $newOrganization */ - $ownedTeams = $user->fresh()->ownedTeams; - $this->assertCount(2, $ownedTeams); - $this->assertTrue($ownedTeams->contains('name', 'Test Organization')); - $newOrganization = $ownedTeams->firstWhere('name', 'Test Organization'); + $ownedOrganizations = $user->fresh()->ownedOrganizations; + $this->assertCount(2, $ownedOrganizations); + $this->assertTrue($ownedOrganizations->contains('name', 'Test Organization')); + $newOrganization = $ownedOrganizations->firstWhere('name', 'Test Organization'); /** @var Member $member */ $member = Member::query()->whereBelongsTo($user, 'user')->whereBelongsTo($newOrganization, 'organization')->firstOrFail(); $this->assertSame(Role::Owner->value, $member->role); diff --git a/tests/Feature/DeleteOrganizationTest.php b/tests/Feature/DeleteOrganizationTest.php index a0c1f9d4..10a5f4e1 100644 --- a/tests/Feature/DeleteOrganizationTest.php +++ b/tests/Feature/DeleteOrganizationTest.php @@ -36,15 +36,15 @@ class DeleteOrganizationTest extends TestCase // Assert $this->assertNull($organization->fresh()); - $this->assertCount(1, $otherUser->fresh()->teams); - $this->assertFalse($otherUser->fresh()->teams->first()->is($organization)); + $this->assertCount(1, $otherUser->fresh()->organizations); + $this->assertFalse($otherUser->fresh()->organizations->first()->is($organization)); } public function test_personal_organizations_can_be_deleted_but_user_gets_an_new_one_if_this_is_the_only_one_left(): void { // Arrange $user = User::factory()->withPersonalOrganization()->create(); - $organization = $user->currentTeam; + $organization = $user->currentOrganization; $this->actingAs($user); // Act @@ -55,7 +55,7 @@ class DeleteOrganizationTest extends TestCase $this->assertDatabaseMissing(Organization::class, [ 'id' => $organization->getKey(), ]); - $this->assertTrue($user->currentTeam->isNot($organization)); + $this->assertTrue($user->currentOrganization->isNot($organization)); } public function test_organization_can_not_be_deleted_if_user_is_not_owner(): void diff --git a/tests/Feature/InviteTeamMemberTest.php b/tests/Feature/InviteTeamMemberTest.php index cff02996..24c37fb5 100644 --- a/tests/Feature/InviteTeamMemberTest.php +++ b/tests/Feature/InviteTeamMemberTest.php @@ -24,7 +24,7 @@ class InviteTeamMemberTest extends TestCase $this->actingAs($user = User::factory()->withPersonalOrganization()->create()); // Act - $response = $this->post('/teams/'.$user->currentTeam->id.'/members', [ + $response = $this->post('/teams/'.$user->currentOrganization->id.'/members', [ 'email' => 'test@example.com', 'role' => 'admin', ]); @@ -42,7 +42,7 @@ class InviteTeamMemberTest extends TestCase $this->actingAs($user = User::factory()->withPersonalOrganization()->create()); - $invitation = $user->currentTeam->teamInvitations()->create([ + $invitation = $user->currentOrganization->organizationInvitations()->create([ 'email' => 'test@example.com', 'role' => 'admin', ]); @@ -52,7 +52,7 @@ class InviteTeamMemberTest extends TestCase // Assert $response->assertStatus(403); - $this->assertCount(1, $user->currentTeam->fresh()->teamInvitations); + $this->assertCount(1, $user->currentOrganization->fresh()->organizationInvitations); } public function test_team_member_invitations_can_be_accepted(): void @@ -61,7 +61,7 @@ class InviteTeamMemberTest extends TestCase Mail::fake(); $owner = User::factory()->withPersonalOrganization()->create(); $user = User::factory()->withPersonalOrganization()->create(); - $invitation = $owner->currentTeam->teamInvitations()->create([ + $invitation = $owner->currentOrganization->organizationInvitations()->create([ 'email' => $user->email, 'role' => Role::Employee->value, ]); @@ -76,10 +76,10 @@ class InviteTeamMemberTest extends TestCase $response = $this->get($acceptUrl); // Assert - $this->assertCount(0, $owner->currentTeam->fresh()->teamInvitations); + $this->assertCount(0, $owner->currentOrganization->fresh()->organizationInvitations); $user->refresh(); $this->assertCount(2, $user->organizations); - $this->assertContains($owner->currentTeam->getKey(), $user->organizations->pluck('id')); + $this->assertContains($owner->currentOrganization->getKey(), $user->organizations->pluck('id')); } public function test_team_member_invitations_of_placeholder_can_be_accepted_and_migrates_date_to_real_user(): void @@ -88,15 +88,15 @@ class InviteTeamMemberTest extends TestCase Mail::fake(); $placeholder = User::factory()->placeholder()->create(); $owner = User::factory()->withPersonalOrganization()->create(); - $placeholderMember = Member::factory()->role(Role::Placeholder)->forOrganization($owner->currentTeam)->forUser($placeholder)->create(); + $placeholderMember = Member::factory()->role(Role::Placeholder)->forOrganization($owner->currentOrganization)->forUser($placeholder)->create(); - $timeEntries = TimeEntry::factory()->forOrganization($owner->currentTeam)->forMember($placeholderMember)->createMany(5); + $timeEntries = TimeEntry::factory()->forOrganization($owner->currentOrganization)->forMember($placeholderMember)->createMany(5); $user = User::factory()->withPersonalOrganization()->create([ 'email' => $placeholder->email, ]); - $invitation = $owner->currentTeam->teamInvitations()->create([ + $invitation = $owner->currentOrganization->organizationInvitations()->create([ 'email' => $user->email, 'role' => Role::Employee->value, ]); @@ -114,9 +114,9 @@ class InviteTeamMemberTest extends TestCase $response->assertRedirect(); $user->refresh(); $this->assertDatabaseMissing(User::class, ['id' => $placeholder->id]); - $this->assertCount(0, $owner->currentTeam->fresh()->teamInvitations); + $this->assertCount(0, $owner->currentOrganization->fresh()->organizationInvitations); $this->assertCount(2, $user->organizations); - $this->assertContains($owner->currentTeam->getKey(), $user->organizations->pluck('id')); + $this->assertContains($owner->currentOrganization->getKey(), $user->organizations->pluck('id')); $this->assertCount(5, $user->timeEntries); } @@ -126,7 +126,7 @@ class InviteTeamMemberTest extends TestCase Mail::fake(); $owner = User::factory()->withPersonalOrganization()->create(); $user = User::factory()->withPersonalOrganization()->create(); - $invitation = $owner->currentTeam->teamInvitations()->create([ + $invitation = $owner->currentOrganization->organizationInvitations()->create([ 'email' => 'firstname.lastname@mail.test', 'role' => Role::Employee->value, ]); @@ -141,7 +141,7 @@ class InviteTeamMemberTest extends TestCase $response = $this->get($acceptUrl); // Assert - $this->assertCount(1, $owner->currentTeam->fresh()->teamInvitations); + $this->assertCount(1, $owner->currentOrganization->fresh()->organizationInvitations); $user->refresh(); $this->assertCount(1, $user->organizations); } diff --git a/tests/Feature/LeaveTeamTest.php b/tests/Feature/LeaveTeamTest.php index 07741446..b7c7134d 100644 --- a/tests/Feature/LeaveTeamTest.php +++ b/tests/Feature/LeaveTeamTest.php @@ -17,17 +17,17 @@ class LeaveTeamTest extends TestCase // Arrange $user = User::factory()->withPersonalOrganization()->create(); - $user->currentTeam->users()->attach( + $user->currentOrganization->users()->attach( $otherUser = User::factory()->create(), ['role' => 'admin'] ); $this->actingAs($otherUser); // Act - $response = $this->delete('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id); + $response = $this->delete('/teams/'.$user->currentOrganization->id.'/members/'.$otherUser->id); // Assert $response->assertStatus(403); - $this->assertCount(2, $user->currentTeam->fresh()->users); + $this->assertCount(2, $user->currentOrganization->fresh()->users); } } diff --git a/tests/Feature/RemoveTeamMemberTest.php b/tests/Feature/RemoveTeamMemberTest.php index 49ecbeb4..ae716391 100644 --- a/tests/Feature/RemoveTeamMemberTest.php +++ b/tests/Feature/RemoveTeamMemberTest.php @@ -17,12 +17,12 @@ class RemoveTeamMemberTest extends TestCase // Arrange $this->actingAs($user = User::factory()->withPersonalOrganization()->create()); - $user->currentTeam->users()->attach( + $user->currentOrganization->users()->attach( $otherUser = User::factory()->create(), ['role' => 'admin'] ); // Act - $response = $this->delete('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id); + $response = $this->delete('/teams/'.$user->currentOrganization->id.'/members/'.$otherUser->id); // Assert $response->assertStatus(403); diff --git a/tests/Feature/UpdateTeamMemberRoleTest.php b/tests/Feature/UpdateTeamMemberRoleTest.php index 7d3b6634..3cf5ce58 100644 --- a/tests/Feature/UpdateTeamMemberRoleTest.php +++ b/tests/Feature/UpdateTeamMemberRoleTest.php @@ -19,12 +19,12 @@ class UpdateTeamMemberRoleTest extends TestCase $user = User::factory()->withPersonalOrganization()->create(); $this->actingAs($user); - $user->currentTeam->users()->attach( + $user->currentOrganization->users()->attach( $otherUser = User::factory()->create(), ['role' => 'admin'] ); // Act - $response = $this->put('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id, [ + $response = $this->put('/teams/'.$user->currentOrganization->id.'/members/'.$otherUser->id, [ 'role' => Role::Employee->value, ]); diff --git a/tests/Feature/UpdateTeamTest.php b/tests/Feature/UpdateTeamTest.php index 40efd4ea..66a17ffc 100644 --- a/tests/Feature/UpdateTeamTest.php +++ b/tests/Feature/UpdateTeamTest.php @@ -32,15 +32,15 @@ class UpdateTeamTest extends TestCase $this->actingAs($user); // Act - $response = $this->put('/teams/'.$user->currentTeam->id, [ + $response = $this->put('/teams/'.$user->currentOrganization->id, [ 'name' => 'Test Organization', 'currency' => 'USD', ]); // Assert $response->assertValid(errorBag: 'updateTeamName'); - $this->assertCount(1, $user->fresh()->ownedTeams); - $organization = $user->currentTeam->fresh(); + $this->assertCount(1, $user->fresh()->ownedOrganizations); + $organization = $user->currentOrganization->fresh(); $this->assertEquals('Test Organization', $organization->name); $this->assertEquals('USD', $organization->currency); } diff --git a/tests/Unit/Filament/Resources/UserResourceTest.php b/tests/Unit/Filament/Resources/UserResourceTest.php index 33f789b9..8ed95003 100644 --- a/tests/Unit/Filament/Resources/UserResourceTest.php +++ b/tests/Unit/Filament/Resources/UserResourceTest.php @@ -4,9 +4,11 @@ declare(strict_types=1); namespace Tests\Unit\Filament\Resources; +use App\Enums\Role; use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers; use App\Filament\Resources\TimeEntryResource; use App\Filament\Resources\UserResource; +use App\Models\Member; use App\Models\Organization; use App\Models\User; use App\Service\DeletionService; @@ -103,7 +105,7 @@ class UserResourceTest extends FilamentTestCase $this->assertSame($userFake->email, $user->email); $this->assertSame($userFake->timezone, $user->timezone); $this->assertSame($userFake->week_start->value, $user->week_start->value); - $organization = $user->ownedTeams()->first(); + $organization = $user->ownedOrganizations()->first(); $this->assertNotNull($organization); $this->assertSame('EUR', $organization->currency); $this->assertTrue(Hash::check('password', $user->password)); @@ -152,7 +154,9 @@ class UserResourceTest extends FilamentTestCase // Arrange $user = User::factory()->create(); $ownedOrganization = Organization::factory()->withOwner($user)->create(); + Member::factory()->forOrganization($ownedOrganization)->forUser($user)->role(Role::Owner)->create(); $organization = Organization::factory()->create(); + Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create(); // Act $response = Livewire::test(UserResource\RelationManagers\OrganizationsRelationManager::class, [ @@ -163,7 +167,7 @@ class UserResourceTest extends FilamentTestCase // Assert $response->assertSuccessful(); $response->assertCanSeeTableRecords($user->organizations()->get()); - $response->assertCanNotSeeTableRecords($user->ownedTeams()->get()); + $response->assertCanSeeTableRecords($user->ownedOrganizations()->get()); } public function test_can_list_related_owned_organizations(): void @@ -171,7 +175,9 @@ class UserResourceTest extends FilamentTestCase // Arrange $user = User::factory()->create(); $ownedOrganization = Organization::factory()->withOwner($user)->create(); + Member::factory()->forOrganization($ownedOrganization)->forUser($user)->role(Role::Owner)->create(); $organization = Organization::factory()->create(); + Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create(); // Act $response = Livewire::test(UserResource\RelationManagers\OwnedOrganizationsRelationManager::class, [ @@ -181,7 +187,7 @@ class UserResourceTest extends FilamentTestCase // Assert $response->assertSuccessful(); - $response->assertCanSeeTableRecords($user->ownedTeams()->get()); - $response->assertCanNotSeeTableRecords($user->organizations()->get()); + $response->assertCanSeeTableRecords($user->ownedOrganizations()->get()); + $response->assertCanNotSeeTableRecords([$organization]); } } diff --git a/tests/Unit/Model/UserModelTest.php b/tests/Unit/Model/UserModelTest.php index d58afbf5..bbb99e35 100644 --- a/tests/Unit/Model/UserModelTest.php +++ b/tests/Unit/Model/UserModelTest.php @@ -62,6 +62,9 @@ class UserModelTest extends ModelTestAbstract $user->organizations()->attach($organization, [ 'role' => Role::Employee->value, ]); + $owner->organizations()->attach($organization, [ + 'role' => Role::Owner->value, + ]); $otherOrganization = Organization::factory()->create(); $otherUser = User::factory()->create(); $otherUser->organizations()->attach($otherOrganization, [ @@ -80,6 +83,70 @@ class UserModelTest extends ModelTestAbstract $this->assertContains($owner->getKey(), $userIds); } + public function test_is_member_of_organization_returns_true_for_user_attached_to_organization(): void + { + // Arrange + $organization = Organization::factory()->create(); + $user = User::factory()->create(); + $user->organizations()->attach($organization, [ + 'role' => Role::Employee->value, + ]); + + // Act + $isMemberOfOrganization = $user->isMemberOfOrganization($organization); + + // Assert + $this->assertTrue($isMemberOfOrganization); + } + + public function test_is_member_of_organization_returns_false_for_user_not_attached_to_organization(): void + { + // Arrange + $organization = Organization::factory()->create(); + $user = User::factory()->create(); + + // Act + $isMemberOfOrganization = $user->isMemberOfOrganization($organization); + + // Assert + $this->assertFalse($isMemberOfOrganization); + } + + public function test_is_member_of_organization_uses_loaded_organizations_relation(): void + { + // Arrange + $organization = Organization::factory()->create(); + $user = User::factory() + ->attachToOrganization($organization, [ + 'role' => Role::Employee->value, + ]) + ->create(); + $user->load('organizations'); + + // Act + $isMemberOfOrganization = $user->isMemberOfOrganization($organization); + + // Assert + $this->assertTrue($isMemberOfOrganization); + } + + public function test_is_member_of_organization_does_not_query_when_organizations_relation_is_loaded(): void + { + // Arrange + $organization = Organization::factory()->create(); + $user = User::factory()->create(); + $user->load('organizations'); + $user->organizations()->attach($organization, [ + 'role' => Role::Employee->value, + ]); + + // Act + $isMemberOfOrganization = $user->isMemberOfOrganization($organization); + + // Assert + $this->assertFalse($isMemberOfOrganization); + } + public function test_it_has_many_time_entries(): void { // Arrange