diff --git a/.env.example b/.env.example index 11b68eda..61d2819e 100644 --- a/.env.example +++ b/.env.example @@ -6,8 +6,8 @@ APP_URL=https://solidtime.test SUPER_ADMINS=admin@example.com -LOG_CHANNEL=stack -LOG_DEPRECATIONS_CHANNEL=null +LOG_CHANNEL=single +LOG_DEPRECATIONS_CHANNEL=deprecation LOG_LEVEL=debug DB_CONNECTION=pgsql diff --git a/.gitignore b/.gitignore index 5476deed..e9d56a37 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ yarn-error.log /k8s /_ide_helper.php /.phpstorm.meta.php +/.rnd diff --git a/app/Actions/Jetstream/DeleteOrganization.php b/app/Actions/Jetstream/DeleteOrganization.php index 8682e49d..f2694d58 100644 --- a/app/Actions/Jetstream/DeleteOrganization.php +++ b/app/Actions/Jetstream/DeleteOrganization.php @@ -5,6 +5,7 @@ namespace App\Actions\Jetstream; use App\Models\Organization; +use App\Service\DeletionService; use Laravel\Jetstream\Contracts\DeletesTeams; class DeleteOrganization implements DeletesTeams @@ -12,8 +13,8 @@ class DeleteOrganization implements DeletesTeams /** * Delete the given team. */ - public function delete(Organization $team): void + public function delete(Organization $organization): void { - $team->purge(); + app(DeletionService::class)->deleteOrganization($organization); } } diff --git a/app/Actions/Jetstream/DeleteUser.php b/app/Actions/Jetstream/DeleteUser.php index 0fcda600..ab118ea1 100644 --- a/app/Actions/Jetstream/DeleteUser.php +++ b/app/Actions/Jetstream/DeleteUser.php @@ -4,51 +4,25 @@ namespace App\Actions\Jetstream; -use App\Models\Organization; +use App\Exceptions\Api\ApiException; use App\Models\User; -use Illuminate\Support\Facades\DB; -use Laravel\Jetstream\Contracts\DeletesTeams; +use App\Service\DeletionService; +use Illuminate\Validation\ValidationException; use Laravel\Jetstream\Contracts\DeletesUsers; class DeleteUser implements DeletesUsers { - /** - * The team deleter implementation. - * - * @var \Laravel\Jetstream\Contracts\DeletesTeams - */ - protected $deletesTeams; - - /** - * Create a new action instance. - */ - public function __construct(DeletesTeams $deletesTeams) - { - $this->deletesTeams = $deletesTeams; - } - /** * Delete the given user. */ public function delete(User $user): void { - DB::transaction(function () use ($user) { - $this->deleteTeams($user); - $user->deleteProfilePhoto(); - $user->tokens->each->delete(); - $user->delete(); - }); - } - - /** - * Delete the teams and team associations attached to the user. - */ - protected function deleteTeams(User $user): void - { - $user->teams()->detach(); - - $user->ownedTeams->each(function (Organization $team) { - $this->deletesTeams->delete($team); - }); + try { + app(DeletionService::class)->deleteUser($user); + } catch (ApiException $exception) { + throw ValidationException::withMessages([ + 'password' => $exception->getTranslatedMessage(), + ]); + } } } diff --git a/app/Actions/Jetstream/ValidateOrganizationDeletion.php b/app/Actions/Jetstream/ValidateOrganizationDeletion.php new file mode 100644 index 00000000..5b46ce4c --- /dev/null +++ b/app/Actions/Jetstream/ValidateOrganizationDeletion.php @@ -0,0 +1,28 @@ +userHas($organization, $user, 'organizations:delete')) { + throw new AuthorizationException(); + } + } +} diff --git a/app/Console/Commands/Admin/DeleteOrganizationCommand.php b/app/Console/Commands/Admin/DeleteOrganizationCommand.php new file mode 100644 index 00000000..e32b90d0 --- /dev/null +++ b/app/Console/Commands/Admin/DeleteOrganizationCommand.php @@ -0,0 +1,59 @@ +argument('organization'); + + if (! Str::isUuid($organizationId)) { + $this->error('Organization ID must be a valid UUID.'); + + return self::FAILURE; + + } + + /** @var Organization|null $organization */ + $organization = Organization::find($organizationId); + if ($organization === null) { + $this->error('Organization with ID '.$organizationId.' not found.'); + + return self::FAILURE; + } + + $this->info('Deleting organization with ID '.$organization->getKey()); + + $deletionService->deleteOrganization($organization); + + $this->info('Organization with ID '.$organization->getKey().' has been deleted.'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SelfHost/SelfHostGenerateKeys.php b/app/Console/Commands/SelfHost/SelfHostGenerateKeysCommand.php similarity index 97% rename from app/Console/Commands/SelfHost/SelfHostGenerateKeys.php rename to app/Console/Commands/SelfHost/SelfHostGenerateKeysCommand.php index 89587f7a..3ca3d5ef 100644 --- a/app/Console/Commands/SelfHost/SelfHostGenerateKeys.php +++ b/app/Console/Commands/SelfHost/SelfHostGenerateKeysCommand.php @@ -9,7 +9,7 @@ use Illuminate\Support\Str; use phpseclib3\Crypt\RSA; -class SelfHostGenerateKeys extends Command +class SelfHostGenerateKeysCommand extends Command { /** * The name and signature of the console command. diff --git a/app/Events/BeforeOrganizationDeletion.php b/app/Events/BeforeOrganizationDeletion.php new file mode 100644 index 00000000..44ff3435 --- /dev/null +++ b/app/Events/BeforeOrganizationDeletion.php @@ -0,0 +1,20 @@ +organization = $organization; + } +} diff --git a/app/Exceptions/Api/ApiException.php b/app/Exceptions/Api/ApiException.php index 421afc28..34adf136 100644 --- a/app/Exceptions/Api/ApiException.php +++ b/app/Exceptions/Api/ApiException.php @@ -13,6 +13,11 @@ abstract class ApiException extends Exception { public const string KEY = 'api_exception'; + public function __construct() + { + parent::__construct(static::KEY); + } + /** * Render the exception into an HTTP response. */ diff --git a/app/Exceptions/Api/CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers.php b/app/Exceptions/Api/CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers.php new file mode 100644 index 00000000..507babe5 --- /dev/null +++ b/app/Exceptions/Api/CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers.php @@ -0,0 +1,10 @@ +modelToDelete = $modelToDelete; $this->modelInUse = $modelInUse; } diff --git a/app/Filament/Resources/OrganizationResource.php b/app/Filament/Resources/OrganizationResource.php index 2584c3c7..3d531db3 100644 --- a/app/Filament/Resources/OrganizationResource.php +++ b/app/Filament/Resources/OrganizationResource.php @@ -169,7 +169,6 @@ public static function table(Table $table): Table ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), ]), ]); } diff --git a/app/Filament/Resources/OrganizationResource/Actions/DeleteOrganization.php b/app/Filament/Resources/OrganizationResource/Actions/DeleteOrganization.php new file mode 100644 index 00000000..ed138e96 --- /dev/null +++ b/app/Filament/Resources/OrganizationResource/Actions/DeleteOrganization.php @@ -0,0 +1,47 @@ +icon('heroicon-m-trash'); + $this->action(function (): void { + $result = $this->process(function (Organization $record): bool { + try { + $deletionService = app(DeletionService::class); + $deletionService->deleteOrganization($record); + + return true; + } catch (ApiException $exception) { + $this->failureNotificationTitle($exception->getTranslatedMessage()); + report($exception); + } catch (Throwable $exception) { + $this->failureNotificationTitle(__('exceptions.unknown_error_in_admin_panel')); + report($exception); + } + + return false; + }); + + if (! $result) { + $this->failure(); + + return; + } + + $this->success(); + }); + } +} diff --git a/app/Filament/Resources/OrganizationResource/Pages/EditOrganization.php b/app/Filament/Resources/OrganizationResource/Pages/EditOrganization.php index 3a3a8831..528d4e80 100644 --- a/app/Filament/Resources/OrganizationResource/Pages/EditOrganization.php +++ b/app/Filament/Resources/OrganizationResource/Pages/EditOrganization.php @@ -5,7 +5,6 @@ namespace App\Filament\Resources\OrganizationResource\Pages; use App\Filament\Resources\OrganizationResource; -use Filament\Actions; use Filament\Resources\Pages\EditRecord; class EditOrganization extends EditRecord @@ -15,7 +14,7 @@ class EditOrganization extends EditRecord protected function getHeaderActions(): array { return [ - Actions\DeleteAction::make(), + OrganizationResource\Actions\DeleteOrganization::make(), ]; } } diff --git a/app/Filament/Resources/OrganizationResource/Pages/ViewOrganization.php b/app/Filament/Resources/OrganizationResource/Pages/ViewOrganization.php index 87020e30..36f9feeb 100644 --- a/app/Filament/Resources/OrganizationResource/Pages/ViewOrganization.php +++ b/app/Filament/Resources/OrganizationResource/Pages/ViewOrganization.php @@ -5,7 +5,6 @@ namespace App\Filament\Resources\OrganizationResource\Pages; use App\Filament\Resources\OrganizationResource; -use Filament\Actions\DeleteAction; use Filament\Actions\EditAction; use Filament\Resources\Pages\ViewRecord; @@ -18,8 +17,6 @@ protected function getHeaderActions(): array return [ EditAction::make('edit') ->icon('heroicon-s-pencil'), - DeleteAction::make('delete') - ->icon('heroicon-s-trash'), ]; } } diff --git a/app/Filament/Resources/UserResource/Actions/DeleteUser.php b/app/Filament/Resources/UserResource/Actions/DeleteUser.php new file mode 100644 index 00000000..2b08c661 --- /dev/null +++ b/app/Filament/Resources/UserResource/Actions/DeleteUser.php @@ -0,0 +1,46 @@ +icon('heroicon-m-trash'); + $this->action(function (): void { + $result = $this->process(function (User $record): bool { + try { + $deletionService = app(DeletionService::class); + $deletionService->deleteUser($record); + + return true; + } catch (ApiException $exception) { + $this->failureNotificationTitle($exception->getTranslatedMessage()); + report($exception); + } catch (Throwable $exception) { + $this->failureNotificationTitle(__('exceptions.unknown_error_in_admin_panel')); + report($exception); + } + + return false; + }); + + if (! $result) { + $this->failure(); + + return; + } + + $this->success(); + }); + } +} diff --git a/app/Filament/Resources/UserResource/Pages/EditUser.php b/app/Filament/Resources/UserResource/Pages/EditUser.php index 318b8b10..f7793a9a 100644 --- a/app/Filament/Resources/UserResource/Pages/EditUser.php +++ b/app/Filament/Resources/UserResource/Pages/EditUser.php @@ -5,7 +5,6 @@ namespace App\Filament\Resources\UserResource\Pages; use App\Filament\Resources\UserResource; -use Filament\Actions; use Filament\Resources\Pages\EditRecord; use STS\FilamentImpersonate\Pages\Actions\Impersonate; @@ -17,7 +16,7 @@ protected function getHeaderActions(): array { return [ Impersonate::make()->record($this->getRecord()), - Actions\DeleteAction::make(), + UserResource\Actions\DeleteUser::make(), ]; } } diff --git a/app/Filament/Resources/UserResource/Pages/ViewUser.php b/app/Filament/Resources/UserResource/Pages/ViewUser.php index 146a56da..e2def8ee 100644 --- a/app/Filament/Resources/UserResource/Pages/ViewUser.php +++ b/app/Filament/Resources/UserResource/Pages/ViewUser.php @@ -5,7 +5,6 @@ namespace App\Filament\Resources\UserResource\Pages; use App\Filament\Resources\UserResource; -use Filament\Actions\DeleteAction; use Filament\Actions\EditAction; use Filament\Resources\Pages\ViewRecord; @@ -18,8 +17,6 @@ protected function getHeaderActions(): array return [ EditAction::make('edit') ->icon('heroicon-s-pencil'), - DeleteAction::make('delete') - ->icon('heroicon-s-trash'), ]; } } diff --git a/app/Http/Controllers/Api/V1/Controller.php b/app/Http/Controllers/Api/V1/Controller.php index 5d97fab7..f5997601 100644 --- a/app/Http/Controllers/Api/V1/Controller.php +++ b/app/Http/Controllers/Api/V1/Controller.php @@ -4,13 +4,9 @@ namespace App\Http\Controllers\Api\V1; -use App\Models\Member; use App\Models\Organization; -use App\Models\User; use App\Service\PermissionStore; use Illuminate\Auth\Access\AuthorizationException; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Log; class Controller extends \App\Http\Controllers\Controller { @@ -48,34 +44,4 @@ protected function hasPermission(Organization $organization, string $permission) { return $this->permissionStore->has($organization, $permission); } - - /** - * @throws AuthorizationException - */ - protected function user(): User - { - /** @var User|null $user */ - $user = Auth::user(); - if ($user === null) { - Log::error('This function should only be called in authenticated context'); - throw new AuthorizationException(); - } - - return $user; - } - - /** - * @throws AuthorizationException - */ - protected function member(Organization $organization): Member - { - $user = $this->user(); - $member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first(); - if ($member === null) { - Log::error('This function should only be called in authenticated context after checking the user is a member of the organization'); - throw new AuthorizationException(); - } - - return $member; - } } diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 127499a8..cd021a42 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -4,11 +4,63 @@ namespace App\Http\Controllers; +use App\Models\Member; +use App\Models\Organization; +use App\Models\User; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Routing\Controller as BaseController; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Log; class Controller extends BaseController { - use AuthorizesRequests, ValidatesRequests; + use AuthorizesRequests; + use ValidatesRequests; + + /** + * @throws AuthorizationException + */ + protected function user(): User + { + /** @var User|null $user */ + $user = Auth::user(); + if ($user === null) { + Log::error('This function should only be called in authenticated context'); + throw new AuthorizationException(); + } + + return $user; + } + + /** + * @throws AuthorizationException + */ + protected function member(Organization $organization): Member + { + $user = $this->user(); + /** @var Member|null $member */ + $member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first(); + if ($member === null) { + Log::error('This function should only be called in authenticated context after checking the user is a member of the organization'); + throw new AuthorizationException(); + } + + return $member; + } + + /** + * @throws AuthorizationException + */ + protected function currentOrganization(): Organization + { + $user = $this->user(); + $organization = $user->currentTeam; + if ($organization === null) { + $organization = $user->organizations()->first(); + } + + return $organization; + } } diff --git a/app/Http/Controllers/Web/DashboardController.php b/app/Http/Controllers/Web/DashboardController.php index c8a0322a..4c807ac6 100644 --- a/app/Http/Controllers/Web/DashboardController.php +++ b/app/Http/Controllers/Web/DashboardController.php @@ -4,21 +4,21 @@ namespace App\Http\Controllers\Web; -use App\Models\Organization; -use App\Models\User; use App\Service\DashboardService; use App\Service\PermissionStore; +use Illuminate\Auth\Access\AuthorizationException; use Inertia\Inertia; use Inertia\Response; class DashboardController extends Controller { + /** + * @throws AuthorizationException + */ public function dashboard(DashboardService $dashboardService, PermissionStore $permissionStore): Response { - /** @var User $user */ - $user = auth()->user(); - /** @var Organization $organization */ - $organization = $user->currentTeam; + $user = $this->user(); + $organization = $this->currentOrganization(); $dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60); $weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization); $totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization); diff --git a/app/Models/User.php b/app/Models/User.php index 3984b234..e01213a5 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; @@ -36,11 +37,12 @@ * @property bool $is_placeholder * @property Weekday $week_start * @property string|null $profile_photo_path - * @property-read Organization $currentTeam + * @property-read Organization|null $currentOrganization + * @property-read Organization|null $currentTeam * @property-read string $profile_photo_url * @property Carbon|null $created_at * @property Carbon|null $updated_at - * @property string $current_team_id + * @property string|null $current_team_id * @property Collection $organizations * @property Collection $timeEntries * @property Member $membership @@ -154,6 +156,14 @@ public function timeEntries(): HasMany return $this->hasMany(TimeEntry::class); } + /** + * @return BelongsTo + */ + public function currentOrganization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'current_team_id'); + } + /** * @return HasMany */ diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index 85d41da8..f3fc7b9a 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -12,6 +12,7 @@ use App\Actions\Jetstream\RemoveOrganizationMember; use App\Actions\Jetstream\UpdateMemberRole; use App\Actions\Jetstream\UpdateOrganization; +use App\Actions\Jetstream\ValidateOrganizationDeletion; use App\Enums\Role; use App\Enums\Weekday; use App\Models\Member; @@ -26,6 +27,7 @@ use Inertia\Inertia; use Laravel\Fortify\Fortify; use Laravel\Jetstream\Actions\UpdateTeamMemberRole; +use Laravel\Jetstream\Actions\ValidateTeamDeletion; use Laravel\Jetstream\Jetstream; class JetstreamServiceProvider extends ServiceProvider @@ -56,6 +58,7 @@ public function boot(): void Jetstream::useMembershipModel(Member::class); Jetstream::useTeamInvitationModel(OrganizationInvitation::class); app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class); + app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class); Fortify::registerView(function () { return Inertia::render('Auth/Register', [ 'terms_url' => config('auth.terms_url'), @@ -105,6 +108,7 @@ protected function configurePermissions(): void 'clients:delete', 'organizations:view', 'organizations:update', + 'organizations:delete', 'import', 'invitations:view', 'invitations:create', diff --git a/app/Service/DeletionService.php b/app/Service/DeletionService.php new file mode 100644 index 00000000..d06bd4e7 --- /dev/null +++ b/app/Service/DeletionService.php @@ -0,0 +1,162 @@ +userService = $userService; + } + + public function deleteOrganization(Organization $organization, bool $inTransaction = true): void + { + if ($inTransaction) { + DB::transaction(function () use ($organization) { + $this->deleteOrganization($organization, false); + }); + + return; + } + + Log::debug('Start deleting organization', [ + 'organization_id' => $organization->getKey(), + 'name' => $organization->name, + 'owner_id' => $organization->user_id, + ]); + + BeforeOrganizationDeletion::dispatch($organization); + + // Delete all organization invitations + OrganizationInvitation::query()->whereBelongsTo($organization, 'organization')->delete(); + + // Delete all time entries + TimeEntry::query()->whereBelongsTo($organization, 'organization')->delete(); + + // Delete all tags + Tag::query()->whereBelongsTo($organization, 'organization')->delete(); + + // Delete all tasks + Task::query()->whereBelongsTo($organization, 'organization')->delete(); + + // Delete all project members + ProjectMember::query()->whereBelongsToOrganization($organization)->delete(); + + // Delete all projects + Project::query()->whereBelongsTo($organization, 'organization')->delete(); + + // Delete all clients + Client::query()->whereBelongsTo($organization, 'organization')->delete(); + + // Reset the current organization + $organization->owner() + ->where('current_team_id', $organization->getKey()) + ->update(['current_team_id' => null]); + + $organization->users() + ->where('current_team_id', $organization->getKey()) + ->update(['current_team_id' => null]); + + // Delete all members + $users = $organization->users() + ->with([ + 'currentOrganization', + ]) + ->get(); + $organization->users()->sync([]); + + // Make sure all users have at least one organization + foreach ($users as $user) { + if ($user->is_placeholder) { + $user->delete(); + } else { + $this->userService->makeSureUserHasAtLeastOneOrganization($user); + $this->userService->makeSureUserHasCurrentOrganization($user); + } + } + + // Delete organization + $organization->delete(); + + Log::debug('Finished deleting organization', [ + 'organization_id' => $organization->getKey(), + 'name' => $organization->name, + 'owner_id' => $organization->user_id, + ]); + } + + /** + * @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers + */ + public function deleteUser(User $user, bool $inTransaction = true): void + { + if ($inTransaction) { + DB::transaction(function () use ($user) { + $this->deleteUser($user, false); + }); + + return; + } + + Log::debug('Start deleting user', [ + 'id' => $user->getKey(), + 'name' => $user->name, + 'email' => $user->email, + ]); + + $members = Member::query()->whereBelongsTo($user, 'user') + ->with([ + 'organization', + 'user', + ]) + ->get(); + + foreach ($members as $member) { + if ($member->role === Role::Owner->value && $member->organization->users()->count() > 1) { + throw new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers(); + } + } + + /** @var Member $member */ + foreach ($members as $member) { + if ($member->role === Role::Owner->value) { + // Note: The member needs to be deleted first, otherwise the organization delete function will recreate a new personal organization for the user + $member->delete(); + $this->deleteOrganization($member->organization, false); + } else { + $this->userService->makeMemberToPlaceholder($member); + } + } + + // Note: Since the deletion of the profile photo is not reversible via a database rollback this needs to be done last + $user->deleteProfilePhoto(); + + $user->delete(); + + Log::debug('Finished deleting user', [ + 'id' => $user->getKey(), + 'name' => $user->name, + 'email' => $user->email, + ]); + } +} diff --git a/app/Service/PermissionStore.php b/app/Service/PermissionStore.php index 355a65e4..87394f23 100644 --- a/app/Service/PermissionStore.php +++ b/app/Service/PermissionStore.php @@ -30,6 +30,11 @@ public function has(Organization $organization, string $permission): bool return false; } + return $this->userHas($organization, $user, $permission); + } + + public function userHas(Organization $organization, User $user, string $permission): bool + { if (! isset($this->permissionCache[$user->getKey().'|'.$organization->getKey()])) { if (! $user->belongsToTeam($organization)) { return false; diff --git a/app/Service/UserService.php b/app/Service/UserService.php index 7afeb9ef..048c96df 100644 --- a/app/Service/UserService.php +++ b/app/Service/UserService.php @@ -28,6 +28,11 @@ public function assignOrganizationEntitiesToDifferentUser(Organization $organiza throw new \InvalidArgumentException('User is not a member of the organization'); } + $this->assignOrganizationEntitiesToDifferentMember($organization, $fromUser, $toUser, $toMember); + } + + private function assignOrganizationEntitiesToDifferentMember(Organization $organization, User $fromUser, User $toUser, Member $toMember): void + { // Time entries TimeEntry::query() ->whereBelongsTo($organization, 'organization') @@ -47,6 +52,53 @@ public function assignOrganizationEntitiesToDifferentUser(Organization $organiza ]); } + public function makeMemberToPlaceholder(Member $member): void + { + $user = $member->user; + $placeholderUser = $user->replicate(); + $placeholderUser->is_placeholder = true; + $placeholderUser->save(); + + $member->user()->associate($placeholderUser); + $member->role = Role::Placeholder->value; + $member->save(); + + $this->assignOrganizationEntitiesToDifferentMember($member->organization, $user, $placeholderUser, $member); + $this->makeSureUserHasAtLeastOneOrganization($user); + } + + public function makeSureUserHasAtLeastOneOrganization(User $user): void + { + if ($user->organizations()->count() > 0) { + return; + } + + // Create a new organization + $organization = new Organization(); + $organization->name = $user->name."'s Organization"; + $organization->personal_team = true; + $organization->user_id = $user->id; + $organization->save(); + + // Attach the user to the organization + $organization->users()->attach($user, ['role' => Role::Owner->value]); + + // Set the organization as the user's current organization + $user->currentOrganization()->associate($organization); + $user->save(); + } + + public function makeSureUserHasCurrentOrganization(User $user): void + { + if ($user->currentOrganization !== null) { + return; + } + + $organization = $user->organizations()->first(); + $user->currentOrganization()->associate($organization); + $user->save(); + } + /** * Change the ownership of an organization to a new user. * The previous owner will be demoted to an admin. diff --git a/composer.json b/composer.json index a1e96fa8..9f3c7e72 100644 --- a/composer.json +++ b/composer.json @@ -93,6 +93,9 @@ "test:coverage:report": [ "@php vendor/bin/phpunit --coverage-html=coverage" ], + "coverage-report": [ + "@test:coverage:report" + ], "fix": [ "@php pint" ], diff --git a/config/logging.php b/config/logging.php index 9a6d62c3..0a413716 100644 --- a/config/logging.php +++ b/config/logging.php @@ -144,6 +144,11 @@ 'emergency' => [ 'path' => storage_path('logs/laravel.log'), ], + + 'deprecation' => [ + 'driver' => 'single', + 'path' => storage_path('logs/deprecation.log'), + ], ], ]; diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php index f83ef0cf..4e529a9a 100644 --- a/database/factories/OrganizationFactory.php +++ b/database/factories/OrganizationFactory.php @@ -22,7 +22,7 @@ public function definition(): array { return [ 'name' => $this->faker->unique()->company(), - 'currency' => $this->faker->currencyCode, + 'currency' => $this->faker->currencyCode(), 'billable_rate' => $this->faker->numberBetween(50, 1000) * 100, 'user_id' => User::factory(), 'personal_team' => true, diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index f95cf5bd..eaef14c7 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -9,6 +9,7 @@ use App\Models\Organization; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; /** @@ -86,6 +87,20 @@ public function attachToOrganization(Organization $organization, array $pivot = }); } + public function withProfilePicture(): static + { + $profilePhoto = $this->faker->image(null, 500, 500); + /** @see \Illuminate\Http\FileHelpers::hashName */ + $path = 'profile-photos/'.Str::random(40).'.png'; + Storage::disk(config('jetstream.profile_photo_disk', 'public'))->put($path, $profilePhoto); + + return $this->state(function (array $attributes) use ($path): array { + return [ + 'profile_photo_path' => $path, + ]; + }); + } + /** * Indicate that the user should have a personal team. */ diff --git a/database/migrations/2024_03_26_171253_create_project_members_table.php b/database/migrations/2024_03_26_171253_create_project_members_table.php index 0c974be8..e54a9c62 100644 --- a/database/migrations/2024_03_26_171253_create_project_members_table.php +++ b/database/migrations/2024_03_26_171253_create_project_members_table.php @@ -20,14 +20,14 @@ public function up(): void $table->foreign('project_id') ->references('id') ->on('projects') - ->onDelete('restrict') - ->onUpdate('cascade'); + ->restrictOnDelete() + ->cascadeOnUpdate(); $table->uuid('user_id'); $table->foreign('user_id') ->references('id') ->on('users') - ->onDelete('restrict') - ->onUpdate('cascade'); + ->restrictOnDelete() + ->cascadeOnUpdate(); $table->timestamps(); $table->unique(['project_id', 'user_id']); }); diff --git a/database/migrations/2024_06_07_113443_change_member_id_foreign_keys_to_restrict_on_delete.php b/database/migrations/2024_06_07_113443_change_member_id_foreign_keys_to_restrict_on_delete.php new file mode 100644 index 00000000..8b2af38f --- /dev/null +++ b/database/migrations/2024_06_07_113443_change_member_id_foreign_keys_to_restrict_on_delete.php @@ -0,0 +1,84 @@ +dropForeign(['member_id']); + $table->foreign('member_id') + ->references('id') + ->on('members') + ->restrictOnDelete() + ->cascadeOnUpdate(); + $table->dropForeign(['client_id']); + $table->foreign('client_id') + ->references('id') + ->on('clients') + ->restrictOnDelete() + ->cascadeOnUpdate(); + }); + Schema::table('project_members', function (Blueprint $table) { + $table->dropForeign(['member_id']); + $table->foreign('member_id') + ->references('id') + ->on('members') + ->restrictOnDelete() + ->cascadeOnUpdate(); + }); + Schema::table('organization_invitations', function (Blueprint $table) { + $table->dropForeign(['organization_id']); + $table->foreign('organization_id') + ->references('id') + ->on('organizations') + ->restrictOnDelete() + ->cascadeOnUpdate(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('time_entries', function (Blueprint $table) { + $table->dropForeign(['member_id']); + $table->foreign('member_id') + ->references('id') + ->on('members') + ->cascadeOnDelete() + ->cascadeOnUpdate(); + $table->dropForeign(['client_id']); + $table->foreign('client_id') + ->references('id') + ->on('clients') + ->cascadeOnDelete() + ->cascadeOnUpdate(); + }); + Schema::table('project_members', function (Blueprint $table) { + $table->dropForeign(['member_id']); + $table->foreign('member_id') + ->references('id') + ->on('members') + ->cascadeOnDelete() + ->cascadeOnUpdate(); + }); + Schema::table('organization_invitations', function (Blueprint $table) { + $table->dropForeign(['organization_id']); + $table->foreign('organization_id') + ->references('id') + ->on('organizations') + ->cascadeOnDelete() + ->cascadeOnUpdate(); + }); + } +}; diff --git a/database/schema/pgsql-schema.sql b/database/schema/pgsql-schema.sql new file mode 100644 index 00000000..6aee7895 --- /dev/null +++ b/database/schema/pgsql-schema.sql @@ -0,0 +1,1137 @@ +-- +-- PostgreSQL database dump +-- + +-- Dumped from database version 15.6 (Debian 15.6-1.pgdg120+2) +-- Dumped by pg_dump version 15.6 (Ubuntu 15.6-1.pgdg22.04+1) + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: cache; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.cache ( + key character varying(255) NOT NULL, + value text NOT NULL, + expiration integer NOT NULL +); + + +-- +-- Name: cache_locks; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.cache_locks ( + key character varying(255) NOT NULL, + owner character varying(255) NOT NULL, + expiration integer NOT NULL +); + + +-- +-- Name: clients; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.clients ( + id uuid NOT NULL, + name character varying(255) NOT NULL, + organization_id uuid NOT NULL, + created_at timestamp(0) without time zone, + updated_at timestamp(0) without time zone +); + + +-- +-- Name: customers; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.customers ( + id uuid NOT NULL, + billable_id uuid NOT NULL, + billable_type character varying(255) NOT NULL, + paddle_id character varying(255) NOT NULL, + name character varying(255) NOT NULL, + email character varying(255) NOT NULL, + trial_ends_at timestamp(0) without time zone, + pending_checkout_id character varying(255), + created_at timestamp(0) without time zone, + updated_at timestamp(0) without time zone +); + + +-- +-- Name: failed_jobs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.failed_jobs ( + id uuid NOT NULL, + uuid uuid NOT NULL, + connection text NOT NULL, + queue text NOT NULL, + payload text NOT NULL, + exception text NOT NULL, + failed_at timestamp(0) without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: jobs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.jobs ( + id bigint NOT NULL, + queue character varying(255) NOT NULL, + payload text NOT NULL, + attempts smallint NOT NULL, + reserved_at integer, + available_at integer NOT NULL, + created_at integer NOT NULL +); + + +-- +-- Name: jobs_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.jobs_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: jobs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.jobs_id_seq OWNED BY public.jobs.id; + + +-- +-- Name: members; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.members ( + id uuid NOT NULL, + organization_id uuid NOT NULL, + user_id uuid NOT NULL, + role character varying(255), + billable_rate integer, + created_at timestamp(0) without time zone, + updated_at timestamp(0) without time zone +); + + +-- +-- Name: migrations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.migrations ( + id integer NOT NULL, + migration character varying(255) NOT NULL, + batch integer NOT NULL +); + + +-- +-- Name: migrations_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.migrations_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: migrations_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.migrations_id_seq OWNED BY public.migrations.id; + + +-- +-- Name: oauth_access_tokens; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.oauth_access_tokens ( + id character varying(100) NOT NULL, + user_id uuid, + client_id uuid NOT NULL, + name character varying(255), + scopes text, + revoked boolean NOT NULL, + created_at timestamp(0) without time zone, + updated_at timestamp(0) without time zone, + expires_at timestamp(0) without time zone +); + + +-- +-- Name: oauth_auth_codes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.oauth_auth_codes ( + id character varying(100) NOT NULL, + user_id uuid NOT NULL, + client_id uuid NOT NULL, + scopes text, + revoked boolean NOT NULL, + expires_at timestamp(0) without time zone +); + + +-- +-- Name: oauth_clients; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.oauth_clients ( + id uuid NOT NULL, + user_id uuid, + name character varying(255) NOT NULL, + secret character varying(100), + provider character varying(255), + redirect text NOT NULL, + personal_access_client boolean NOT NULL, + password_client boolean NOT NULL, + revoked boolean NOT NULL, + created_at timestamp(0) without time zone, + updated_at timestamp(0) without time zone +); + + +-- +-- Name: oauth_personal_access_clients; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.oauth_personal_access_clients ( + id bigint NOT NULL, + client_id uuid NOT NULL, + created_at timestamp(0) without time zone, + updated_at timestamp(0) without time zone +); + + +-- +-- Name: oauth_personal_access_clients_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.oauth_personal_access_clients_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: oauth_personal_access_clients_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.oauth_personal_access_clients_id_seq OWNED BY public.oauth_personal_access_clients.id; + + +-- +-- Name: oauth_refresh_tokens; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.oauth_refresh_tokens ( + id character varying(100) NOT NULL, + access_token_id character varying(100) NOT NULL, + revoked boolean NOT NULL, + expires_at timestamp(0) without time zone +); + + +-- +-- Name: organization_invitations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.organization_invitations ( + id uuid NOT NULL, + organization_id uuid NOT NULL, + email character varying(255) NOT NULL, + role character varying(255), + created_at timestamp(0) without time zone, + updated_at timestamp(0) without time zone +); + + +-- +-- Name: organizations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.organizations ( + id uuid NOT NULL, + user_id uuid NOT NULL, + name character varying(255) NOT NULL, + personal_team boolean NOT NULL, + billable_rate integer, + currency character varying(3) NOT NULL, + created_at timestamp(0) without time zone, + updated_at timestamp(0) without time zone +); + + +-- +-- Name: password_reset_tokens; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.password_reset_tokens ( + email character varying(255) NOT NULL, + token character varying(255) NOT NULL, + created_at timestamp(0) without time zone +); + + +-- +-- Name: personal_access_tokens; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.personal_access_tokens ( + id uuid NOT NULL, + tokenable_type character varying(255) NOT NULL, + tokenable_id bigint NOT NULL, + name character varying(255) NOT NULL, + token character varying(64) NOT NULL, + abilities text, + last_used_at timestamp(0) without time zone, + expires_at timestamp(0) without time zone, + created_at timestamp(0) without time zone, + updated_at timestamp(0) without time zone +); + + +-- +-- Name: project_members; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.project_members ( + id uuid NOT NULL, + billable_rate integer, + project_id uuid NOT NULL, + user_id uuid NOT NULL, + created_at timestamp(0) without time zone, + updated_at timestamp(0) without time zone, + member_id uuid NOT NULL +); + + +-- +-- Name: projects; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.projects ( + id uuid NOT NULL, + name character varying(255) NOT NULL, + color character varying(16) NOT NULL, + billable_rate integer, + is_public boolean DEFAULT false NOT NULL, + client_id uuid, + organization_id uuid NOT NULL, + created_at timestamp(0) without time zone, + updated_at timestamp(0) without time zone, + is_billable boolean NOT NULL +); + + +-- +-- Name: sessions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.sessions ( + id character varying(255) NOT NULL, + user_id uuid, + ip_address character varying(45), + user_agent text, + payload text NOT NULL, + last_activity integer NOT NULL +); + + +-- +-- Name: subscription_items; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.subscription_items ( + id uuid NOT NULL, + subscription_id uuid NOT NULL, + product_id character varying(255) NOT NULL, + price_id character varying(255) NOT NULL, + status character varying(255) NOT NULL, + quantity integer NOT NULL, + created_at timestamp(0) without time zone, + updated_at timestamp(0) without time zone +); + + +-- +-- Name: subscriptions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.subscriptions ( + id uuid NOT NULL, + billable_id uuid NOT NULL, + billable_type character varying(255) NOT NULL, + type character varying(255) NOT NULL, + paddle_id character varying(255) NOT NULL, + status character varying(255) NOT NULL, + trial_ends_at timestamp(0) without time zone, + paused_at timestamp(0) without time zone, + ends_at timestamp(0) without time zone, + created_at timestamp(0) without time zone, + updated_at timestamp(0) without time zone +); + + +-- +-- Name: tags; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.tags ( + id uuid NOT NULL, + name character varying(255) NOT NULL, + organization_id uuid NOT NULL, + created_at timestamp(0) without time zone, + updated_at timestamp(0) without time zone +); + + +-- +-- Name: tasks; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.tasks ( + id uuid NOT NULL, + name character varying(500) NOT NULL, + project_id uuid NOT NULL, + organization_id uuid NOT NULL, + created_at timestamp(0) without time zone, + updated_at timestamp(0) without time zone +); + + +-- +-- Name: time_entries; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.time_entries ( + id uuid NOT NULL, + description character varying(500) NOT NULL, + start timestamp(0) without time zone NOT NULL, + "end" timestamp(0) without time zone, + billable_rate integer, + billable boolean DEFAULT false NOT NULL, + user_id uuid NOT NULL, + organization_id uuid NOT NULL, + project_id uuid, + task_id uuid, + tags jsonb, + created_at timestamp(0) without time zone, + updated_at timestamp(0) without time zone, + member_id uuid NOT NULL, + client_id uuid, + is_imported boolean DEFAULT false NOT NULL +); + + +-- +-- Name: transactions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.transactions ( + id uuid NOT NULL, + billable_id uuid NOT NULL, + billable_type character varying(255) NOT NULL, + paddle_id character varying(255) NOT NULL, + paddle_subscription_id character varying(255), + invoice_number character varying(255), + status character varying(255) NOT NULL, + total character varying(255) NOT NULL, + tax character varying(255) NOT NULL, + currency character varying(3) NOT NULL, + billed_at timestamp(0) without time zone NOT NULL, + created_at timestamp(0) without time zone, + updated_at timestamp(0) without time zone +); + + +-- +-- Name: users; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.users ( + id uuid NOT NULL, + name character varying(255) NOT NULL, + email character varying(255) NOT NULL, + email_verified_at timestamp(0) without time zone, + password character varying(255), + remember_token character varying(100), + is_placeholder boolean DEFAULT false NOT NULL, + current_team_id uuid, + profile_photo_path character varying(2048), + timezone character varying(255) NOT NULL, + week_start character varying(255) NOT NULL, + created_at timestamp(0) without time zone, + updated_at timestamp(0) without time zone, + two_factor_secret text, + two_factor_recovery_codes text, + two_factor_confirmed_at timestamp(0) without time zone, + CONSTRAINT users_week_start_check CHECK (((week_start)::text = ANY ((ARRAY['monday'::character varying, 'tuesday'::character varying, 'wednesday'::character varying, 'thursday'::character varying, 'friday'::character varying, 'saturday'::character varying, 'sunday'::character varying])::text[]))) +); + + +-- +-- Name: jobs id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.jobs ALTER COLUMN id SET DEFAULT nextval('public.jobs_id_seq'::regclass); + + +-- +-- Name: migrations id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.migrations ALTER COLUMN id SET DEFAULT nextval('public.migrations_id_seq'::regclass); + + +-- +-- Name: oauth_personal_access_clients id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oauth_personal_access_clients ALTER COLUMN id SET DEFAULT nextval('public.oauth_personal_access_clients_id_seq'::regclass); + + +-- +-- Name: cache_locks cache_locks_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.cache_locks + ADD CONSTRAINT cache_locks_pkey PRIMARY KEY (key); + + +-- +-- Name: cache cache_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.cache + ADD CONSTRAINT cache_pkey PRIMARY KEY (key); + + +-- +-- Name: clients clients_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.clients + ADD CONSTRAINT clients_pkey PRIMARY KEY (id); + + +-- +-- Name: customers customers_paddle_id_unique; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.customers + ADD CONSTRAINT customers_paddle_id_unique UNIQUE (paddle_id); + + +-- +-- Name: customers customers_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.customers + ADD CONSTRAINT customers_pkey PRIMARY KEY (id); + + +-- +-- Name: failed_jobs failed_jobs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.failed_jobs + ADD CONSTRAINT failed_jobs_pkey PRIMARY KEY (id); + + +-- +-- Name: failed_jobs failed_jobs_uuid_unique; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.failed_jobs + ADD CONSTRAINT failed_jobs_uuid_unique UNIQUE (uuid); + + +-- +-- Name: jobs jobs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.jobs + ADD CONSTRAINT jobs_pkey PRIMARY KEY (id); + + +-- +-- Name: migrations migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.migrations + ADD CONSTRAINT migrations_pkey PRIMARY KEY (id); + + +-- +-- Name: oauth_access_tokens oauth_access_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oauth_access_tokens + ADD CONSTRAINT oauth_access_tokens_pkey PRIMARY KEY (id); + + +-- +-- Name: oauth_auth_codes oauth_auth_codes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oauth_auth_codes + ADD CONSTRAINT oauth_auth_codes_pkey PRIMARY KEY (id); + + +-- +-- Name: oauth_clients oauth_clients_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oauth_clients + ADD CONSTRAINT oauth_clients_pkey PRIMARY KEY (id); + + +-- +-- Name: oauth_personal_access_clients oauth_personal_access_clients_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oauth_personal_access_clients + ADD CONSTRAINT oauth_personal_access_clients_pkey PRIMARY KEY (id); + + +-- +-- Name: oauth_refresh_tokens oauth_refresh_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oauth_refresh_tokens + ADD CONSTRAINT oauth_refresh_tokens_pkey PRIMARY KEY (id); + + +-- +-- Name: organization_invitations organization_invitations_organization_id_email_unique; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.organization_invitations + ADD CONSTRAINT organization_invitations_organization_id_email_unique UNIQUE (organization_id, email); + + +-- +-- Name: organization_invitations organization_invitations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.organization_invitations + ADD CONSTRAINT organization_invitations_pkey PRIMARY KEY (id); + + +-- +-- Name: members organization_user_organization_id_user_id_unique; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.members + ADD CONSTRAINT organization_user_organization_id_user_id_unique UNIQUE (organization_id, user_id); + + +-- +-- Name: members organization_user_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.members + ADD CONSTRAINT organization_user_pkey PRIMARY KEY (id); + + +-- +-- Name: organizations organizations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.organizations + ADD CONSTRAINT organizations_pkey PRIMARY KEY (id); + + +-- +-- Name: password_reset_tokens password_reset_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.password_reset_tokens + ADD CONSTRAINT password_reset_tokens_pkey PRIMARY KEY (email); + + +-- +-- Name: personal_access_tokens personal_access_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.personal_access_tokens + ADD CONSTRAINT personal_access_tokens_pkey PRIMARY KEY (id); + + +-- +-- Name: personal_access_tokens personal_access_tokens_token_unique; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.personal_access_tokens + ADD CONSTRAINT personal_access_tokens_token_unique UNIQUE (token); + + +-- +-- Name: project_members project_members_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.project_members + ADD CONSTRAINT project_members_pkey PRIMARY KEY (id); + + +-- +-- Name: project_members project_members_project_id_user_id_unique; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.project_members + ADD CONSTRAINT project_members_project_id_user_id_unique UNIQUE (project_id, user_id); + + +-- +-- Name: projects projects_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.projects + ADD CONSTRAINT projects_pkey PRIMARY KEY (id); + + +-- +-- Name: sessions sessions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.sessions + ADD CONSTRAINT sessions_pkey PRIMARY KEY (id); + + +-- +-- Name: subscription_items subscription_items_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subscription_items + ADD CONSTRAINT subscription_items_pkey PRIMARY KEY (id); + + +-- +-- Name: subscription_items subscription_items_subscription_id_price_id_unique; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subscription_items + ADD CONSTRAINT subscription_items_subscription_id_price_id_unique UNIQUE (subscription_id, price_id); + + +-- +-- Name: subscriptions subscriptions_paddle_id_unique; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subscriptions + ADD CONSTRAINT subscriptions_paddle_id_unique UNIQUE (paddle_id); + + +-- +-- Name: subscriptions subscriptions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subscriptions + ADD CONSTRAINT subscriptions_pkey PRIMARY KEY (id); + + +-- +-- Name: tags tags_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.tags + ADD CONSTRAINT tags_pkey PRIMARY KEY (id); + + +-- +-- Name: tasks tasks_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.tasks + ADD CONSTRAINT tasks_pkey PRIMARY KEY (id); + + +-- +-- Name: time_entries time_entries_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.time_entries + ADD CONSTRAINT time_entries_pkey PRIMARY KEY (id); + + +-- +-- Name: transactions transactions_paddle_id_unique; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.transactions + ADD CONSTRAINT transactions_paddle_id_unique UNIQUE (paddle_id); + + +-- +-- Name: transactions transactions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.transactions + ADD CONSTRAINT transactions_pkey PRIMARY KEY (id); + + +-- +-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + + +-- +-- Name: customers_billable_id_billable_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX customers_billable_id_billable_type_index ON public.customers USING btree (billable_id, billable_type); + + +-- +-- Name: jobs_queue_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX jobs_queue_index ON public.jobs USING btree (queue); + + +-- +-- Name: oauth_access_tokens_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX oauth_access_tokens_user_id_index ON public.oauth_access_tokens USING btree (user_id); + + +-- +-- Name: oauth_auth_codes_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX oauth_auth_codes_user_id_index ON public.oauth_auth_codes USING btree (user_id); + + +-- +-- Name: oauth_clients_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX oauth_clients_user_id_index ON public.oauth_clients USING btree (user_id); + + +-- +-- Name: oauth_refresh_tokens_access_token_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX oauth_refresh_tokens_access_token_id_index ON public.oauth_refresh_tokens USING btree (access_token_id); + + +-- +-- Name: organizations_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX organizations_user_id_index ON public.organizations USING btree (user_id); + + +-- +-- Name: personal_access_tokens_tokenable_type_tokenable_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX personal_access_tokens_tokenable_type_tokenable_id_index ON public.personal_access_tokens USING btree (tokenable_type, tokenable_id); + + +-- +-- Name: sessions_last_activity_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX sessions_last_activity_index ON public.sessions USING btree (last_activity); + + +-- +-- Name: sessions_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX sessions_user_id_index ON public.sessions USING btree (user_id); + + +-- +-- Name: subscriptions_billable_id_billable_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX subscriptions_billable_id_billable_type_index ON public.subscriptions USING btree (billable_id, billable_type); + + +-- +-- Name: tags_created_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX tags_created_at_index ON public.tags USING btree (created_at); + + +-- +-- Name: time_entries_billable_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX time_entries_billable_index ON public.time_entries USING btree (billable); + + +-- +-- Name: time_entries_end_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX time_entries_end_index ON public.time_entries USING btree ("end"); + + +-- +-- Name: time_entries_start_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX time_entries_start_index ON public.time_entries USING btree (start); + + +-- +-- Name: transactions_billable_id_billable_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX transactions_billable_id_billable_type_index ON public.transactions USING btree (billable_id, billable_type); + + +-- +-- Name: transactions_paddle_subscription_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX transactions_paddle_subscription_id_index ON public.transactions USING btree (paddle_subscription_id); + + +-- +-- Name: users_email_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX users_email_unique ON public.users USING btree (email) WHERE (is_placeholder = false); + + +-- +-- Name: clients clients_organization_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.clients + ADD CONSTRAINT clients_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: organization_invitations organization_invitations_organization_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.organization_invitations + ADD CONSTRAINT organization_invitations_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON DELETE CASCADE; + + +-- +-- Name: project_members project_members_member_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.project_members + ADD CONSTRAINT project_members_member_id_foreign FOREIGN KEY (member_id) REFERENCES public.members(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: project_members project_members_project_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.project_members + ADD CONSTRAINT project_members_project_id_foreign FOREIGN KEY (project_id) REFERENCES public.projects(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: project_members project_members_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.project_members + ADD CONSTRAINT project_members_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: projects projects_client_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.projects + ADD CONSTRAINT projects_client_id_foreign FOREIGN KEY (client_id) REFERENCES public.clients(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: projects projects_organization_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.projects + ADD CONSTRAINT projects_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: tags tags_organization_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.tags + ADD CONSTRAINT tags_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: tasks tasks_organization_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.tasks + ADD CONSTRAINT tasks_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: tasks tasks_project_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.tasks + ADD CONSTRAINT tasks_project_id_foreign FOREIGN KEY (project_id) REFERENCES public.projects(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: time_entries time_entries_client_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.time_entries + ADD CONSTRAINT time_entries_client_id_foreign FOREIGN KEY (client_id) REFERENCES public.clients(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: time_entries time_entries_member_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.time_entries + ADD CONSTRAINT time_entries_member_id_foreign FOREIGN KEY (member_id) REFERENCES public.members(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: time_entries time_entries_organization_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.time_entries + ADD CONSTRAINT time_entries_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: time_entries time_entries_project_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.time_entries + ADD CONSTRAINT time_entries_project_id_foreign FOREIGN KEY (project_id) REFERENCES public.projects(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: time_entries time_entries_task_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.time_entries + ADD CONSTRAINT time_entries_task_id_foreign FOREIGN KEY (task_id) REFERENCES public.tasks(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- Name: time_entries time_entries_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.time_entries + ADD CONSTRAINT time_entries_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE RESTRICT; + + +-- +-- PostgreSQL database dump complete +-- + +-- +-- PostgreSQL database dump +-- + +-- Dumped from database version 15.6 (Debian 15.6-1.pgdg120+2) +-- Dumped by pg_dump version 15.6 (Ubuntu 15.6-1.pgdg22.04+1) + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Data for Name: migrations; Type: TABLE DATA; Schema: public; Owner: - +-- + +COPY public.migrations (id, migration, batch) FROM stdin; +1 2014_10_12_000000_create_users_table 1 +2 2014_10_12_100000_create_password_reset_tokens_table 1 +3 2014_10_12_200000_add_two_factor_columns_to_users_table 1 +4 2016_06_01_000001_create_oauth_auth_codes_table 1 +5 2016_06_01_000002_create_oauth_access_tokens_table 1 +6 2016_06_01_000003_create_oauth_refresh_tokens_table 1 +7 2016_06_01_000004_create_oauth_clients_table 1 +8 2016_06_01_000005_create_oauth_personal_access_clients_table 1 +9 2019_05_03_000001_create_customers_table 1 +10 2019_05_03_000002_create_subscriptions_table 1 +11 2019_05_03_000003_create_subscription_items_table 1 +12 2019_05_03_000004_create_transactions_table 1 +13 2019_08_19_000000_create_failed_jobs_table 1 +14 2019_12_14_000001_create_personal_access_tokens_table 1 +15 2020_05_21_100000_create_organizations_table 1 +16 2020_05_21_200000_create_organization_user_table 1 +17 2020_05_21_300000_create_organization_invitations_table 1 +18 2024_01_16_161030_create_sessions_table 1 +19 2024_01_20_110218_create_clients_table 1 +20 2024_01_20_110439_create_projects_table 1 +21 2024_01_20_110444_create_tasks_table 1 +22 2024_01_20_110452_create_tags_table 1 +23 2024_01_20_110837_create_time_entries_table 1 +24 2024_03_26_171253_create_project_members_table 1 +25 2024_04_11_150130_create_jobs_table 1 +26 2024_04_12_095010_create_cache_table 1 +27 2024_05_07_134711_move_from_user_id_to_member_id_in_project_members_table 1 +28 2024_05_07_141842_move_from_user_id_to_member_id_in_time_entries_table 1 +29 2024_05_13_171020_rename_table_organization_user_to_members 1 +31 2024_05_22_151226_add_client_id_to_time_entries_table 2 +36 2024_05_30_175801_add_is_billable_column_to_projects_table 3 +37 2024_05_30_175825_add_is_imported_column_to_time_entries_table 3 +\. + + +-- +-- Name: migrations_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.migrations_id_seq', 37, true); + + +-- +-- PostgreSQL database dump complete +-- + diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 79308e7b..685bb558 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -83,10 +83,10 @@ public function run(): void ->count(5) ->forMember($userWithMultipleOrganizationsAcmeMember) ->create(); - $client = Client::factory()->forOrganization($organizationAcme)->create([ + $acmeClient = Client::factory()->forOrganization($organizationAcme)->create([ 'name' => 'Big Company', ]); - $bigCompanyProject = Project::factory()->forOrganization($organizationAcme)->forClient($client)->create([ + $bigCompanyProject = Project::factory()->forOrganization($organizationAcme)->forClient($acmeClient)->create([ 'name' => 'Big Company Project', ]); ProjectMember::factory()->forProject($bigCompanyProject)->forMember($userAcmeEmployeeMember)->create(); @@ -105,11 +105,11 @@ public function run(): void 'name' => 'Internal Project', ]); - $organization2Owner = User::factory()->create([ + $rivalOwner = User::factory()->create([ 'name' => 'Other Owner', 'email' => 'owner@rival-company.test', ]); - $organizationRival = Organization::factory()->withOwner($organization2Owner)->create([ + $organizationRival = Organization::factory()->withOwner($rivalOwner)->create([ 'name' => 'Rival Corp', 'personal_team' => true, 'currency' => 'USD', @@ -120,9 +120,12 @@ public function run(): void ]); $userRivalManagerMember = Member::factory()->forUser($userRivalManager)->forOrganization($organizationRival)->role(Role::Admin)->create(); $userWithMultipleOrganizationsRivalMember = Member::factory()->forUser($userWithMultipleOrganizations)->forOrganization($organizationRival)->role(Role::Employee)->create(); - $otherCompanyProject = Project::factory()->forOrganization($organizationRival)->forClient($client)->create([ + $rivalClient = Client::factory()->forOrganization($organizationRival)->create([ 'name' => 'Scale Company', ]); + $otherCompanyProject = Project::factory()->forOrganization($organizationRival)->forClient($rivalClient)->create([ + 'name' => 'Scale Company - Project ABC', + ]); ProjectMember::factory()->forProject($otherCompanyProject)->forMember($userRivalManagerMember)->create(); ProjectMember::factory()->forProject($otherCompanyProject)->forMember($userWithMultipleOrganizationsRivalMember)->create(); TimeEntry::factory() diff --git a/lang/en/exceptions.php b/lang/en/exceptions.php index fa2bf17b..c25aa5fe 100644 --- a/lang/en/exceptions.php +++ b/lang/en/exceptions.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers; use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization; use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException; @@ -19,5 +20,7 @@ UserIsAlreadyMemberOfProjectApiException::KEY => 'User is already a member of the project', EntityStillInUseApiException::KEY => 'The :modelToDelete is still used by a :modelInUse and can not be deleted.', CanNotRemoveOwnerFromOrganization::KEY => 'Can not remove owner from organization', + CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers::KEY => 'Can not delete user who is owner of organization with multiple members. Please delete the organization first.', ], + 'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.', ]; diff --git a/tests/Feature/DeleteAccountTest.php b/tests/Feature/DeleteAccountTest.php index 5bfc18d7..ff1bdd44 100644 --- a/tests/Feature/DeleteAccountTest.php +++ b/tests/Feature/DeleteAccountTest.php @@ -4,6 +4,9 @@ namespace Tests\Feature; +use App\Enums\Role; +use App\Models\Member; +use App\Models\Organization; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -42,4 +45,24 @@ public function test_correct_password_must_be_provided_before_account_can_be_del // Assert $this->assertNotNull($user->fresh()); } + + public function test_user_account_can_not_be_deleted_if_attached_to_a_organization_with_multiple_users(): void + { + // Arrange + $user = User::factory()->create(); + $organization = Organization::factory()->withOwner($user)->create(); + $userMember = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Owner)->create(); + $otherUser = User::factory()->create(); + $otherMember = Member::factory()->forOrganization($organization)->forUser($otherUser)->role(Role::Admin)->create(); + $this->actingAs($user); + + // Act + $response = $this->delete('/user', [ + 'password' => 'password', + ]); + + // Assert + $response->assertInvalid(['password']); + $this->assertNotNull($user->fresh()); + } } diff --git a/tests/Feature/DeleteTeamTest.php b/tests/Feature/DeleteTeamTest.php index dff844ef..e0f3becd 100644 --- a/tests/Feature/DeleteTeamTest.php +++ b/tests/Feature/DeleteTeamTest.php @@ -4,6 +4,8 @@ namespace Tests\Feature; +use App\Enums\Role; +use App\Models\Member; use App\Models\Organization; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -13,30 +15,70 @@ class DeleteTeamTest extends TestCase { use RefreshDatabase; - public function test_teams_can_be_deleted(): void + public function test_teams_can_be_deleted_and_users_of_the_organization_that_have_no_organization_get_a_new_one(): void { - $this->actingAs($user = User::factory()->withPersonalOrganization()->create()); + // Arrange + $user = User::factory()->withPersonalOrganization()->create(); + $this->actingAs($user); - $user->ownedTeams()->save($team = Organization::factory()->make([ + $organization = Organization::factory()->withOwner($user)->create([ 'personal_team' => false, - ])); + ]); + Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Owner)->create(); - $team->users()->attach( - $otherUser = User::factory()->create(), ['role' => 'test-role'] + $otherUser = User::factory()->create(); + $organization->users()->attach( + $otherUser, ['role' => 'test-role'] ); - $response = $this->delete('/teams/'.$team->getKey()); + // Act + $response = $this->withoutExceptionHandling()->delete('/teams/'.$organization->getKey()); - $this->assertNull($team->fresh()); - $this->assertCount(0, $otherUser->fresh()->teams); + // Assert + $this->assertNull($organization->fresh()); + $this->assertCount(1, $otherUser->fresh()->teams); + $this->assertFalse($otherUser->fresh()->teams->first()->is($organization)); } - public function test_personal_teams_cant_be_deleted(): void + public function test_personal_teams_can_be_deleted_but_user_gets_an_new_one_if_this_is_the_only_one_left(): void { - $this->actingAs($user = User::factory()->withPersonalOrganization()->create()); + // Arrange + $user = User::factory()->withPersonalOrganization()->create(); + $organization = $user->currentTeam; + $this->actingAs($user); - $response = $this->delete('/teams/'.$user->currentTeam->getKey()); + // Act + $response = $this->delete('/teams/'.$organization->getKey()); - $this->assertNotNull($user->currentTeam->fresh()); + // Assert + $user->refresh(); + $this->assertDatabaseMissing(Organization::class, [ + 'id' => $organization->getKey(), + ]); + $this->assertTrue($user->currentTeam->isNot($organization)); + } + + public function test_organization_can_not_be_deleted_if_user_is_not_owner(): void + { + // Arrange + $user = User::factory()->withPersonalOrganization()->create(); + $organization = Organization::factory()->withOwner($user)->create([ + 'personal_team' => false, + ]); + $this->actingAs($user); + + $otherUser = User::factory()->create(); + $organization->users()->attach( + $otherUser, ['role' => Role::Admin->value] + ); + + // Act + $response = $this->delete('/teams/'.$organization->getKey()); + + // Assert + $response->assertForbidden(); + $this->assertDatabaseHas(Organization::class, [ + 'id' => $organization->getKey(), + ]); } } diff --git a/tests/Unit/Console/Commands/Admin/DeleteOrganizationCommandTest.php b/tests/Unit/Console/Commands/Admin/DeleteOrganizationCommandTest.php new file mode 100644 index 00000000..c3df5e76 --- /dev/null +++ b/tests/Unit/Console/Commands/Admin/DeleteOrganizationCommandTest.php @@ -0,0 +1,53 @@ +create(); + $this->mock(DeletionService::class, function (MockInterface $mock) use ($organization): void { + $mock->shouldReceive('deleteOrganization') + ->withArgs(fn (Organization $organizationArg) => $organizationArg->is($organization)) + ->once(); + }); + + // Act + $this->artisan('admin:delete-organization', ['organization' => $organization->getKey()]) + ->expectsOutput("Deleting organization with ID {$organization->getKey()}") + ->expectsOutput("Organization with ID {$organization->getKey()} has been deleted.") + ->assertExitCode(0); + } + + public function test_it_fails_if_organization_does_not_exist(): void + { + // Arrange + $organizationId = Str::uuid()->toString(); + + // Act + $this->artisan('admin:delete-organization', ['organization' => $organizationId]) + ->expectsOutput('Organization with ID '.$organizationId.' not found.') + ->assertExitCode(1); + } + + public function test_it_fails_if_organization_id_is_not_a_valid_uuid(): void + { + // Arrange + $organizationId = 'invalid-uuid'; + + // Act + $this->artisan('admin:delete-organization', ['organization' => $organizationId]) + ->expectsOutput('Organization ID must be a valid UUID.') + ->assertExitCode(1); + } +} diff --git a/tests/Unit/Console/Commands/SelfHost/SelfHostGenerateKeysCommandTest.php b/tests/Unit/Console/Commands/SelfHost/SelfHostGenerateKeysCommandTest.php new file mode 100644 index 00000000..043f7102 --- /dev/null +++ b/tests/Unit/Console/Commands/SelfHost/SelfHostGenerateKeysCommandTest.php @@ -0,0 +1,42 @@ +withoutMockingConsoleOutput()->artisan('self-host:generate-keys'); + + // Assert + $this->assertSame(Command::SUCCESS, $exitCode); + $output = Artisan::output(); + $this->assertStringContainsString('APP_KEY="base64:', $output); + $this->assertStringContainsString('PASSPORT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----', $output); + $this->assertStringContainsString('PASSPORT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----', $output); + } + + public function test_generates_app_key_and_passport_keys_in_yaml_format_if_requested(): void + { + // Arrange + + // Act + $exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:generate-keys --format=yaml'); + + // Assert + $this->assertSame(Command::SUCCESS, $exitCode); + $output = Artisan::output(); + $this->assertStringContainsString('APP_KEY: "base64:', $output); + $this->assertStringContainsString("PASSPORT_PRIVATE_KEY: |\n -----BEGIN PRIVATE KEY-----", $output); + $this->assertStringContainsString("PASSPORT_PUBLIC_KEY: |\n -----BEGIN PUBLIC KEY-----", $output); + } +} diff --git a/tests/Unit/Filament/FilamentTestCase.php b/tests/Unit/Filament/FilamentTestCase.php index d0065259..3d8274b7 100644 --- a/tests/Unit/Filament/FilamentTestCase.php +++ b/tests/Unit/Filament/FilamentTestCase.php @@ -5,13 +5,10 @@ namespace Tests\Unit\Filament; use Filament\Facades\Filament; -use Illuminate\Foundation\Testing\RefreshDatabase; -use Tests\TestCase; +use Tests\TestCaseWithDatabase; -abstract class FilamentTestCase extends TestCase +abstract class FilamentTestCase extends TestCaseWithDatabase { - use RefreshDatabase; - protected function setUp(): void { parent::setUp(); diff --git a/tests/Unit/Filament/OrganizationResourceTest.php b/tests/Unit/Filament/OrganizationResourceTest.php index 86c6be60..3a55f025 100644 --- a/tests/Unit/Filament/OrganizationResourceTest.php +++ b/tests/Unit/Filament/OrganizationResourceTest.php @@ -7,8 +7,10 @@ use App\Filament\Resources\OrganizationResource; use App\Models\Organization; use App\Models\User; +use App\Service\DeletionService; use Illuminate\Support\Facades\Config; use Livewire\Livewire; +use Mockery\MockInterface; class OrganizationResourceTest extends FilamentTestCase { @@ -50,4 +52,23 @@ public function test_can_see_edit_page_of_organization(): void // Assert $response->assertSuccessful(); } + + public function test_can_delete_a_organization(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $this->mock(DeletionService::class, function (MockInterface $mock) use ($user): void { + $mock->shouldReceive('deleteOrganization') + ->withArgs(fn (Organization $organizationArg) => $organizationArg->is($user->organization)) + ->once(); + }); + + // Act + $response = Livewire::test(OrganizationResource\Pages\EditOrganization::class, ['record' => $user->organization->getKey()]) + ->callAction('delete') + ->assertHasNoActionErrors(); + + // Assert + $response->assertSuccessful(); + } } diff --git a/tests/Unit/Filament/UserResourceTest.php b/tests/Unit/Filament/UserResourceTest.php index 4933fe1d..30ecc61d 100644 --- a/tests/Unit/Filament/UserResourceTest.php +++ b/tests/Unit/Filament/UserResourceTest.php @@ -4,10 +4,13 @@ namespace Tests\Unit\Filament; +use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers; use App\Filament\Resources\UserResource; use App\Models\User; +use App\Service\DeletionService; use Illuminate\Support\Facades\Config; use Livewire\Livewire; +use Mockery\MockInterface; class UserResourceTest extends FilamentTestCase { @@ -46,4 +49,42 @@ public function test_can_see_edit_page_of_user(): void // Assert $response->assertSuccessful(); } + + public function test_can_delete_a_user(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $this->mock(DeletionService::class, function (MockInterface $mock) use ($user): void { + $mock->shouldReceive('deleteUser') + ->withArgs(fn (User $userArg) => $userArg->is($user->user)) + ->once(); + }); + + // Act + $response = Livewire::test(UserResource\Pages\EditUser::class, ['record' => $user->user->getKey()]) + ->callAction('delete'); + + // Assert + $response->assertHasNoActionErrors(); + $response->assertSuccessful(); + } + + public function test_delete_user_shows_error_notification_on_failure(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $this->mock(DeletionService::class, function (MockInterface $mock) use ($user): void { + $mock->shouldReceive('deleteUser') + ->withArgs(fn (User $userArg) => $userArg->is($user->user)) + ->andThrow(new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers()); + }); + + // Act + $response = Livewire::test(UserResource\Pages\EditUser::class, ['record' => $user->user->getKey()]) + ->callAction('delete'); + + // Assert + $response->assertNotified(__('exceptions.api.can_not_delete_user_who_is_owner_of_organization_with_multiple_members')); + $response->assertSuccessful(); + } } diff --git a/tests/Unit/Model/ClientModelTest.php b/tests/Unit/Model/ClientModelTest.php index 210a1d00..b193515c 100644 --- a/tests/Unit/Model/ClientModelTest.php +++ b/tests/Unit/Model/ClientModelTest.php @@ -7,7 +7,11 @@ use App\Models\Client; use App\Models\Organization; use App\Models\Project; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +#[CoversClass(Client::class)] +#[UsesClass(Client::class)] class ClientModelTest extends ModelTestAbstract { public function test_it_belongs_to_a_organization(): void diff --git a/tests/Unit/Model/ProjectMemberModelTest.php b/tests/Unit/Model/ProjectMemberModelTest.php index a8effad7..88d4cd45 100644 --- a/tests/Unit/Model/ProjectMemberModelTest.php +++ b/tests/Unit/Model/ProjectMemberModelTest.php @@ -8,7 +8,11 @@ use App\Models\Organization; use App\Models\Project; use App\Models\ProjectMember; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +#[CoversClass(ProjectMember::class)] +#[UsesClass(ProjectMember::class)] class ProjectMemberModelTest extends ModelTestAbstract { public function test_it_belongs_to_a_project(): void diff --git a/tests/Unit/Model/ProjectModelTest.php b/tests/Unit/Model/ProjectModelTest.php index 1eeb8060..33aec647 100644 --- a/tests/Unit/Model/ProjectModelTest.php +++ b/tests/Unit/Model/ProjectModelTest.php @@ -10,7 +10,11 @@ use App\Models\Project; use App\Models\ProjectMember; use App\Models\Task; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +#[CoversClass(Project::class)] +#[UsesClass(Project::class)] class ProjectModelTest extends ModelTestAbstract { public function test_it_belongs_to_a_organization(): void diff --git a/tests/Unit/Model/TagModelTest.php b/tests/Unit/Model/TagModelTest.php index 44232abb..2e1d88c0 100644 --- a/tests/Unit/Model/TagModelTest.php +++ b/tests/Unit/Model/TagModelTest.php @@ -6,7 +6,11 @@ use App\Models\Organization; use App\Models\Tag; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +#[CoversClass(Tag::class)] +#[UsesClass(Tag::class)] class TagModelTest extends ModelTestAbstract { public function test_it_belongs_to_a_organization(): void diff --git a/tests/Unit/Model/TaskModelTest.php b/tests/Unit/Model/TaskModelTest.php index c2c07480..e86b8027 100644 --- a/tests/Unit/Model/TaskModelTest.php +++ b/tests/Unit/Model/TaskModelTest.php @@ -10,7 +10,11 @@ use App\Models\ProjectMember; use App\Models\Task; use App\Models\TimeEntry; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +#[CoversClass(Task::class)] +#[UsesClass(Task::class)] class TaskModelTest extends ModelTestAbstract { public function test_it_belongs_to_a_organization(): void diff --git a/tests/Unit/Model/TimeEntryModelTest.php b/tests/Unit/Model/TimeEntryModelTest.php index 561ee283..48826896 100644 --- a/tests/Unit/Model/TimeEntryModelTest.php +++ b/tests/Unit/Model/TimeEntryModelTest.php @@ -11,7 +11,11 @@ use App\Models\TimeEntry; use App\Models\User; use Illuminate\Support\Carbon; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +#[CoversClass(TimeEntry::class)] +#[UsesClass(TimeEntry::class)] class TimeEntryModelTest extends ModelTestAbstract { public function test_it_belongs_to_a_user(): void diff --git a/tests/Unit/Rules/ColorRuleTest.php b/tests/Unit/Rules/ColorRuleTest.php index 3df5ee22..e098b41d 100644 --- a/tests/Unit/Rules/ColorRuleTest.php +++ b/tests/Unit/Rules/ColorRuleTest.php @@ -6,8 +6,12 @@ use App\Rules\ColorRule; use Illuminate\Support\Facades\Validator; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; use Tests\TestCase; +#[CoversClass(ColorRule::class)] +#[UsesClass(ColorRule::class)] class ColorRuleTest extends TestCase { public function test_validation_passes_if_value_is_valid_color(): void diff --git a/tests/Unit/Rules/CurrencyRuleTest.php b/tests/Unit/Rules/CurrencyRuleTest.php index ea785239..f57edfe2 100644 --- a/tests/Unit/Rules/CurrencyRuleTest.php +++ b/tests/Unit/Rules/CurrencyRuleTest.php @@ -6,8 +6,12 @@ use App\Rules\CurrencyRule; use Illuminate\Support\Facades\Validator; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; use Tests\TestCase; +#[CoversClass(CurrencyRule::class)] +#[UsesClass(CurrencyRule::class)] class CurrencyRuleTest extends TestCase { public function test_validation_passes_if_value_is_valid_currency_code(): void diff --git a/tests/Unit/Service/BillableRateServiceTest.php b/tests/Unit/Service/BillableRateServiceTest.php index b8169369..b88da207 100644 --- a/tests/Unit/Service/BillableRateServiceTest.php +++ b/tests/Unit/Service/BillableRateServiceTest.php @@ -12,8 +12,12 @@ use App\Models\User; use App\Service\BillableRateService; use Illuminate\Foundation\Testing\RefreshDatabase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; use Tests\TestCase; +#[CoversClass(BillableRateService::class)] +#[UsesClass(BillableRateService::class)] class BillableRateServiceTest extends TestCase { use RefreshDatabase; diff --git a/tests/Unit/Service/DashboardServiceTest.php b/tests/Unit/Service/DashboardServiceTest.php index 0ec42db8..2fdb808c 100644 --- a/tests/Unit/Service/DashboardServiceTest.php +++ b/tests/Unit/Service/DashboardServiceTest.php @@ -15,8 +15,12 @@ use App\Service\DashboardService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; use Tests\TestCase; +#[CoversClass(DashboardService::class)] +#[UsesClass(DashboardService::class)] class DashboardServiceTest extends TestCase { use RefreshDatabase; diff --git a/tests/Unit/Service/DeletionServiceTest.php b/tests/Unit/Service/DeletionServiceTest.php new file mode 100644 index 00000000..cf207326 --- /dev/null +++ b/tests/Unit/Service/DeletionServiceTest.php @@ -0,0 +1,346 @@ +deletionService = app(DeletionService::class); + } + + /** + * Creates an organization with all relations. + * It is important that every relation has at least two entries, to test for possible lazy loading issues. + * + * @return object{ + * organization: Organization, + * clients: Collection, + * projects: Collection, + * projectMembers: Collection, + * tags: Collection, + * members: Collection, + * tasks: Collection, + * timeEntries: Collection, + * owner: User + * } + */ + private function createOrganizationWithAllRelations(): object + { + $userOwner = User::factory()->create(); + $userEmployee = User::factory()->withProfilePicture()->create(); + $userPlaceholder = User::factory()->placeholder()->create(); + + $organization = Organization::factory()->withOwner($userOwner)->create(); + + // Create a personal organization for the employee + $personalOrganizationOfEmployee = Organization::factory()->withOwner($userEmployee)->create(); + $personalOrganizationMember = Member::factory()->forUser($userEmployee)->forOrganization($personalOrganizationOfEmployee)->create(); + + // Set the current organizations for the users + $userOwner->update(['current_team_id' => $organization->id]); + $userEmployee->update(['current_team_id' => $personalOrganizationOfEmployee->id]); + $userPlaceholder->update(['current_team_id' => null]); + + $memberOwner = Member::factory()->forUser($userOwner)->forOrganization($organization)->role(Role::Owner)->create(); + $memberEmployee = Member::factory()->forUser($userEmployee)->forOrganization($organization)->role(Role::Employee)->create(); + $memberPlaceholder = Member::factory()->forUser($userPlaceholder)->forOrganization($organization)->role(Role::Placeholder)->create(); + $members = collect([$memberOwner, $memberEmployee, $memberPlaceholder]); + + $clients = Client::factory()->forOrganization($organization)->createMany(2); + + $projectWithClient = Project::factory()->forClient($clients->get(0))->forOrganization($organization)->create(); + $projectWithoutClient = Project::factory()->forOrganization($organization)->create(); + $projects = collect([$projectWithClient, $projectWithoutClient]); + + $projectMemberOwner = ProjectMember::factory()->forMember($memberOwner)->forProject($projectWithClient)->create(); + $projectMemberEmployee = ProjectMember::factory()->forMember($memberEmployee)->forProject($projectWithClient)->create(); + $projectMembers = collect([$projectMemberOwner, $projectMemberEmployee]); + + $tags = Tag::factory()->forOrganization($organization)->createMany(2); + + $task1 = Task::factory()->forProject($projectWithClient)->forOrganization($organization)->create(); + $task2 = Task::factory()->forProject($projectWithoutClient)->forOrganization($organization)->create(); + $tasks = collect([$task1, $task2]); + + $timeEntries = TimeEntry::factory()->forOrganization($organization)->forMember($memberOwner)->createMany(2); + $timeEntriesWithTask = TimeEntry::factory()->forTask($task1)->forOrganization($organization)->forMember($memberEmployee)->createMany(2); + $timeEntriesWithProject = TimeEntry::factory()->forProject($projectWithClient)->forOrganization($organization)->forMember($memberPlaceholder)->createMany(2); + $timeEntries = $timeEntries->merge($timeEntriesWithTask)->merge($timeEntriesWithProject); + + return (object) [ + 'organization' => $organization, + 'clients' => $clients, + 'projects' => $projects, + 'projectMembers' => $projectMembers, + 'tags' => $tags, + 'members' => $members, + 'tasks' => $tasks, + 'timeEntries' => $timeEntries, + 'owner' => $userOwner, + ]; + } + + private function assertOrganizationDeleted(Organization $organization): void + { + Event::assertDispatched(function (BeforeOrganizationDeletion $event) use ($organization) { + return $event->organization->is($organization); + }, 1); + $this->assertSame(0, Organization::query()->where('id', $organization->id)->count()); + $this->assertSame(0, Client::query()->whereBelongsTo($organization, 'organization')->count()); + $this->assertSame(0, Project::query()->whereBelongsTo($organization, 'organization')->count()); + $this->assertSame(0, ProjectMember::query()->whereBelongsToOrganization($organization)->count()); + $this->assertSame(0, Tag::query()->whereBelongsTo($organization, 'organization')->count()); + $this->assertSame(0, Member::query()->whereBelongsTo($organization, 'organization')->count()); + $this->assertSame(0, Task::query()->whereBelongsTo($organization, 'organization')->count()); + $this->assertSame(0, TimeEntry::query()->whereBelongsTo($organization, 'organization')->count()); + } + + private function assertOrganizationNothingDeleted(Organization $organization, bool $specialCase = false): void + { + $this->assertSame(1, Organization::query()->where('id', $organization->id)->count()); + $this->assertSame(2, Client::query()->whereBelongsTo($organization, 'organization')->count()); + $this->assertSame(2, Project::query()->whereBelongsTo($organization, 'organization')->count()); + $this->assertSame(2, ProjectMember::query()->whereBelongsToOrganization($organization)->count()); + $this->assertSame(2, Tag::query()->whereBelongsTo($organization, 'organization')->count()); + $this->assertSame(3, Member::query()->whereBelongsTo($organization, 'organization')->count()); + $this->assertSame(2, Task::query()->whereBelongsTo($organization, 'organization')->count()); + $this->assertSame($specialCase ? 7 : 6, TimeEntry::query()->whereBelongsTo($organization, 'organization')->count()); + } + + public function test_delete_organization_deletes_all_resources_of_the_organization_but_does_not_delete_other_resources(): void + { + // Arrange + $organization = $this->createOrganizationWithAllRelations(); + $otherOrganization = $this->createOrganizationWithAllRelations(); + + // Act + $this->deletionService->deleteOrganization($organization->organization); + + // Assert + $this->assertOrganizationDeleted($organization->organization); + $this->assertOrganizationNothingDeleted($otherOrganization->organization); + Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug' + && $log->message === 'Start deleting organization' + && $log->context['organization_id'] === $organization->organization->getKey(), + 1 + ); + Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug' + && $log->message === 'Finished deleting organization' + && $log->context['organization_id'] === $organization->organization->getKey(), + 1 + ); + } + + public function test_delete_organization_rolls_back_on_error_if_transaction_is_active(): void + { + // Arrange + $organization = $this->createOrganizationWithAllRelations(); + $otherOrganization = $this->createOrganizationWithAllRelations(); + $brokenTimeEntry = TimeEntry::factory()->forOrganization($otherOrganization->organization)->forProject($organization->projects->get(0))->create(); + + // Act + try { + $this->deletionService->deleteOrganization($organization->organization); + $this->fail(); + } catch (QueryException) { + $this->assertTrue(true); + } + + // Assert + Event::assertNotDispatched(function (BeforeOrganizationDeletion $event) use ($otherOrganization): bool { + return $event->organization->is($otherOrganization->organization); + }); + Event::assertDispatched(function (BeforeOrganizationDeletion $event) use ($organization): bool { + return $event->organization->is($organization->organization); + }, 1); + $this->assertOrganizationNothingDeleted($organization->organization); + $this->assertOrganizationNothingDeleted($otherOrganization->organization, true); + Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug' + && $log->message === 'Start deleting organization' + && $log->context['organization_id'] === $organization->organization->getKey(), + 1 + ); + Log::assertNotLogged(fn (LogEntry $log) => $log->level === 'debug' + && $log->message === 'Finished deleting organization' + && $log->context['organization_id'] === $organization->organization->getKey() + ); + } + + public function test_delete_user_fails_if_user_is_owner_of_an_organization_with_multiple_members(): void + { + // Arrange + $organization = $this->createOrganizationWithAllRelations(); + $memberOwner = $organization->owner; + + // Act + try { + $this->deletionService->deleteUser($memberOwner); + $this->fail(); + } catch (CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers $exception) { + // Assert + $this->assertTrue(true); + } + } + + public function test_delete_user_rolls_back_on_error_if_transaction_is_active(): void + { + // Arrange + $user = User::factory()->create(); + $organization = Organization::factory()->create(); + $memberOwner = Member::factory()->forUser($user)->forOrganization($organization)->role(Role::Owner)->create(); + $otherOrganization = Organization::factory()->create(); + + $brokenTimeEntry = TimeEntry::factory()->forOrganization($otherOrganization)->forMember($memberOwner)->create(); + + // Act + try { + $this->deletionService->deleteUser($user); + $this->fail(); + } catch (QueryException) { + $this->assertTrue(true); + } + + // Assert + $this->assertDatabaseHas(User::class, [ + 'id' => $user->getKey(), + ]); + $this->assertDatabaseHas(Organization::class, [ + 'id' => $organization->getKey(), + ]); + $this->assertDatabaseHas(Member::class, [ + 'id' => $memberOwner->getKey(), + ]); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $brokenTimeEntry->getKey(), + ]); + Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug' + && $log->message === 'Start deleting user' + && $log->context['id'] === $user->getKey(), + 1 + ); + Log::assertNotLogged(fn (LogEntry $log) => $log->level === 'debug' + && $log->message === 'Finished deleting user' + && $log->context['id'] === $user->getKey() + ); + } + + public function test_delete_user_deletes_all_resources_of_the_user_but_does_not_delete_other_resources(): void + { + // Arrange + $user = User::factory()->withProfilePicture()->withPersonalOrganization()->create(); + $otherUser = User::factory()->withProfilePicture()->withPersonalOrganization()->create(); + Storage::disk('public')->assertExists($user->profile_photo_path); + Storage::disk('public')->assertExists($otherUser->profile_photo_path); + + // Act + $this->deletionService->deleteUser($user); + + // Assert + $this->assertDatabaseMissing(User::class, [ + 'id' => $user->getKey(), + ]); + $this->assertDatabaseHas(User::class, [ + 'id' => $otherUser->getKey(), + ]); + $this->assertDatabaseMissing(Organization::class, [ + 'id' => $user->current_team_id, + ]); + $this->assertDatabaseHas(Organization::class, [ + 'id' => $otherUser->current_team_id, + ]); + $this->assertDatabaseHas(Member::class, [ + 'user_id' => $otherUser->getKey(), + ]); + $this->assertDatabaseMissing(Member::class, [ + 'user_id' => $user->getKey(), + ]); + Storage::disk('public')->assertMissing($user->profile_photo_path); + Storage::disk('public')->assertExists($otherUser->profile_photo_path); + Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug' + && $log->message === 'Start deleting user' + && $log->context['id'] === $user->getKey(), + 1 + ); + Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug' + && $log->message === 'Finished deleting user' + && $log->context['id'] === $user->getKey(), + 1 + ); + } + + public function test_delete_user_deletes_owned_organizations_that_have_only_one_member_and_makes_makes_the_user_placeholder_in_not_owned_organizations(): void + { + // Arrange + $user = User::factory()->create(); + $organizationOwned = Organization::factory()->withOwner($user)->create(); + $organizationNotOwned = Organization::factory()->create(); + $memberOwned = Member::factory()->forUser($user)->forOrganization($organizationOwned)->role(Role::Owner)->create(); + $memberNotOwned = Member::factory()->forUser($user)->forOrganization($organizationNotOwned)->role(Role::Employee)->create(); + + // Act + $this->deletionService->deleteUser($user); + + // Assert + $this->assertDatabaseMissing(User::class, [ + 'id' => $user->getKey(), + ]); + $this->assertDatabaseMissing(Organization::class, [ + 'id' => $organizationOwned->getKey(), + ]); + $this->assertDatabaseHas(Organization::class, [ + 'id' => $organizationNotOwned->getKey(), + ]); + $this->assertDatabaseMissing(Member::class, [ + 'id' => $memberOwned->getKey(), + ]); + $this->assertDatabaseHas(Member::class, [ + 'id' => $memberNotOwned->getKey(), + 'organization_id' => $organizationNotOwned->getKey(), + 'role' => Role::Placeholder->value, + ]); + Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug' + && $log->message === 'Start deleting user' + && $log->context['id'] === $user->getKey(), + 1 + ); + Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug' + && $log->message === 'Finished deleting user' + && $log->context['id'] === $user->getKey(), + 1 + ); + } +} diff --git a/tests/Unit/Service/Import/ImportDatabaseHelperTest.php b/tests/Unit/Service/Import/ImportDatabaseHelperTest.php index db325cdd..129f2c74 100644 --- a/tests/Unit/Service/Import/ImportDatabaseHelperTest.php +++ b/tests/Unit/Service/Import/ImportDatabaseHelperTest.php @@ -9,8 +9,12 @@ use App\Models\User; use App\Service\Import\ImportDatabaseHelper; use Illuminate\Foundation\Testing\RefreshDatabase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; use Tests\TestCase; +#[CoversClass(ImportDatabaseHelper::class)] +#[UsesClass(ImportDatabaseHelper::class)] class ImportDatabaseHelperTest extends TestCase { use RefreshDatabase; diff --git a/tests/Unit/Service/Import/ImportServiceTest.php b/tests/Unit/Service/Import/ImportServiceTest.php index 63310996..233d31a6 100644 --- a/tests/Unit/Service/Import/ImportServiceTest.php +++ b/tests/Unit/Service/Import/ImportServiceTest.php @@ -5,11 +5,17 @@ namespace Tests\Unit\Service\Import; use App\Models\Organization; +use App\Service\Import\Importers\ImporterProvider; use App\Service\Import\ImportService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Storage; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; use Tests\TestCase; +#[CoversClass(ImportService::class)] +#[CoversClass(ImporterProvider::class)] +#[UsesClass(ImportService::class)] class ImportServiceTest extends TestCase { use RefreshDatabase; diff --git a/tests/Unit/Service/Import/Importer/ClockifyProjectsImporterTest.php b/tests/Unit/Service/Import/Importers/ClockifyProjectsImporterTest.php similarity index 77% rename from tests/Unit/Service/Import/Importer/ClockifyProjectsImporterTest.php rename to tests/Unit/Service/Import/Importers/ClockifyProjectsImporterTest.php index a4e3549b..52a42a28 100644 --- a/tests/Unit/Service/Import/Importer/ClockifyProjectsImporterTest.php +++ b/tests/Unit/Service/Import/Importers/ClockifyProjectsImporterTest.php @@ -2,12 +2,20 @@ declare(strict_types=1); -namespace Tests\Unit\Service\Import\Importer; +namespace Tests\Unit\Service\Import\Importers; use App\Models\Organization; use App\Service\Import\Importers\ClockifyProjectsImporter; +use App\Service\Import\Importers\DefaultImporter; +use App\Service\Import\Importers\ImportException; use Illuminate\Support\Facades\Storage; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +#[CoversClass(ClockifyProjectsImporter::class)] +#[CoversClass(ImportException::class)] +#[CoversClass(DefaultImporter::class)] +#[UsesClass(ClockifyProjectsImporter::class)] class ClockifyProjectsImporterTest extends ImporterTestAbstract { public function test_import_of_test_file_succeeds(): void diff --git a/tests/Unit/Service/Import/Importer/ClockifyTimeEntriesImporterTest.php b/tests/Unit/Service/Import/Importers/ClockifyTimeEntriesImporterTest.php similarity index 90% rename from tests/Unit/Service/Import/Importer/ClockifyTimeEntriesImporterTest.php rename to tests/Unit/Service/Import/Importers/ClockifyTimeEntriesImporterTest.php index 170a10ac..30c3758b 100644 --- a/tests/Unit/Service/Import/Importer/ClockifyTimeEntriesImporterTest.php +++ b/tests/Unit/Service/Import/Importers/ClockifyTimeEntriesImporterTest.php @@ -2,13 +2,21 @@ declare(strict_types=1); -namespace Tests\Unit\Service\Import\Importer; +namespace Tests\Unit\Service\Import\Importers; use App\Models\Organization; use App\Models\TimeEntry; use App\Service\Import\Importers\ClockifyTimeEntriesImporter; +use App\Service\Import\Importers\DefaultImporter; +use App\Service\Import\Importers\ImportException; use Illuminate\Support\Facades\Storage; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +#[CoversClass(ClockifyTimeEntriesImporter::class)] +#[CoversClass(ImportException::class)] +#[CoversClass(DefaultImporter::class)] +#[UsesClass(ClockifyTimeEntriesImporter::class)] class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract { public function test_import_of_test_file_succeeds(): void diff --git a/tests/Unit/Service/Import/Importers/ImporterProviderTest.php b/tests/Unit/Service/Import/Importers/ImporterProviderTest.php new file mode 100644 index 00000000..ba0ec646 --- /dev/null +++ b/tests/Unit/Service/Import/Importers/ImporterProviderTest.php @@ -0,0 +1,46 @@ +registerImporter('some_provider_importer', ClockifyProjectsImporter::class); + + // Assert + $importer = $provider->getImporter('some_provider_importer'); + $this->assertSame(ClockifyProjectsImporter::class, $importer::class); + } + + public function test_get_importer_keys_return_the_keys_of_the_available_importers(): void + { + // Arrange + $provider = new ImporterProvider(); + + // Act + $keys = $provider->getImporterKeys(); + + // Assert + $this->assertSame([ + 'toggl_time_entries', + 'toggl_data_importer', + 'clockify_time_entries', + 'clockify_projects', + ], $keys); + } +} diff --git a/tests/Unit/Service/Import/Importer/ImporterTestAbstract.php b/tests/Unit/Service/Import/Importers/ImporterTestAbstract.php similarity index 98% rename from tests/Unit/Service/Import/Importer/ImporterTestAbstract.php rename to tests/Unit/Service/Import/Importers/ImporterTestAbstract.php index c9953db7..bc76d9bc 100644 --- a/tests/Unit/Service/Import/Importer/ImporterTestAbstract.php +++ b/tests/Unit/Service/Import/Importers/ImporterTestAbstract.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Unit\Service\Import\Importer; +namespace Tests\Unit\Service\Import\Importers; use App\Enums\Role; use App\Models\Client; diff --git a/tests/Unit/Service/Import/Importer/TogglDataImporterTest.php b/tests/Unit/Service/Import/Importers/TogglDataImporterTest.php similarity index 90% rename from tests/Unit/Service/Import/Importer/TogglDataImporterTest.php rename to tests/Unit/Service/Import/Importers/TogglDataImporterTest.php index 64b768d8..eea87225 100644 --- a/tests/Unit/Service/Import/Importer/TogglDataImporterTest.php +++ b/tests/Unit/Service/Import/Importers/TogglDataImporterTest.php @@ -2,17 +2,24 @@ declare(strict_types=1); -namespace Tests\Unit\Service\Import\Importer; +namespace Tests\Unit\Service\Import\Importers; use App\Models\Organization; +use App\Service\Import\Importers\DefaultImporter; use App\Service\Import\Importers\ImportException; use App\Service\Import\Importers\TogglDataImporter; use Exception; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; use Spatie\TemporaryDirectory\TemporaryDirectory; use ZipArchive; +#[CoversClass(TogglDataImporter::class)] +#[CoversClass(ImportException::class)] +#[CoversClass(DefaultImporter::class)] +#[UsesClass(TogglDataImporter::class)] class TogglDataImporterTest extends ImporterTestAbstract { private function createTestZip(string $folder): string diff --git a/tests/Unit/Service/Import/Importer/TogglTimeEntriesImporterTest.php b/tests/Unit/Service/Import/Importers/TogglTimeEntriesImporterTest.php similarity index 91% rename from tests/Unit/Service/Import/Importer/TogglTimeEntriesImporterTest.php rename to tests/Unit/Service/Import/Importers/TogglTimeEntriesImporterTest.php index 523df916..094876a1 100644 --- a/tests/Unit/Service/Import/Importer/TogglTimeEntriesImporterTest.php +++ b/tests/Unit/Service/Import/Importers/TogglTimeEntriesImporterTest.php @@ -2,13 +2,21 @@ declare(strict_types=1); -namespace Tests\Unit\Service\Import\Importer; +namespace Tests\Unit\Service\Import\Importers; use App\Models\Organization; use App\Models\TimeEntry; +use App\Service\Import\Importers\DefaultImporter; +use App\Service\Import\Importers\ImportException; use App\Service\Import\Importers\TogglTimeEntriesImporter; use Illuminate\Support\Facades\Storage; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +#[CoversClass(TogglTimeEntriesImporter::class)] +#[CoversClass(ImportException::class)] +#[CoversClass(DefaultImporter::class)] +#[UsesClass(TogglTimeEntriesImporter::class)] class TogglTimeEntriesImporterTest extends ImporterTestAbstract { public function test_import_of_test_file_succeeds(): void diff --git a/tests/Unit/Service/PermissionStoreTest.php b/tests/Unit/Service/PermissionStoreTest.php index 925401da..4640544b 100644 --- a/tests/Unit/Service/PermissionStoreTest.php +++ b/tests/Unit/Service/PermissionStoreTest.php @@ -10,8 +10,12 @@ use App\Service\PermissionStore; use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Jetstream\Jetstream; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; use Tests\TestCase; +#[CoversClass(PermissionStore::class)] +#[UsesClass(PermissionStore::class)] class PermissionStoreTest extends TestCase { use RefreshDatabase; diff --git a/tests/Unit/Service/TimeEntryAggregationServiceTest.php b/tests/Unit/Service/TimeEntryAggregationServiceTest.php index dd50d77f..cb7c96f9 100644 --- a/tests/Unit/Service/TimeEntryAggregationServiceTest.php +++ b/tests/Unit/Service/TimeEntryAggregationServiceTest.php @@ -11,8 +11,12 @@ use App\Models\TimeEntry; use App\Service\TimeEntryAggregationService; use Illuminate\Support\Carbon; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; use Tests\TestCaseWithDatabase; +#[CoversClass(TimeEntryAggregationService::class)] +#[UsesClass(TimeEntryAggregationService::class)] class TimeEntryAggregationServiceTest extends TestCaseWithDatabase { private TimeEntryAggregationService $service; diff --git a/tests/Unit/Service/TimezoneServiceTest.php b/tests/Unit/Service/TimezoneServiceTest.php index 11948bc2..638734d1 100644 --- a/tests/Unit/Service/TimezoneServiceTest.php +++ b/tests/Unit/Service/TimezoneServiceTest.php @@ -8,9 +8,13 @@ use App\Service\TimezoneService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Log; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; use Tests\TestCase; use TiMacDonald\Log\LogEntry; +#[CoversClass(TimezoneService::class)] +#[UsesClass(TimezoneService::class)] class TimezoneServiceTest extends TestCase { use RefreshDatabase; diff --git a/tests/Unit/Service/UserServiceTest.php b/tests/Unit/Service/UserServiceTest.php index 8d2ce367..f136499d 100644 --- a/tests/Unit/Service/UserServiceTest.php +++ b/tests/Unit/Service/UserServiceTest.php @@ -13,12 +13,24 @@ use App\Models\User; use App\Service\UserService; use Illuminate\Foundation\Testing\RefreshDatabase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; use Tests\TestCase; +#[CoversClass(UserService::class)] +#[UsesClass(UserService::class)] class UserServiceTest extends TestCase { use RefreshDatabase; + private UserService $userService; + + protected function setUp(): void + { + parent::setUp(); + $this->userService = app(UserService::class); + } + public function test_assign_organization_entities_to_different_user(): void { // Arrange @@ -36,9 +48,7 @@ public function test_assign_organization_entities_to_different_user(): void ProjectMember::factory()->forProject($project)->forMember($fromUserMember)->create(); // Act - /** @var UserService $userService */ - $userService = app(UserService::class); - $userService->assignOrganizationEntitiesToDifferentUser($organization, $fromUser, $toUser); + $this->userService->assignOrganizationEntitiesToDifferentUser($organization, $fromUser, $toUser); // Assert $this->assertSame(3, TimeEntry::query()->whereBelongsTo($toUser, 'user')->count()); @@ -49,6 +59,22 @@ public function test_assign_organization_entities_to_different_user(): void $this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUser, 'user')->count()); } + public function test_assign_organization_entities_to_different_user_fails_if_new_user_is_not_member_of_organization(): void + { + // Arrange + $organization = Organization::factory()->create(); + $fromUser = User::factory()->create(); + $toUser = User::factory()->create(); + $fromUserMember = Member::factory()->forOrganization($organization)->forUser($fromUser)->create(); + + // Act + try { + $this->userService->assignOrganizationEntitiesToDifferentUser($organization, $fromUser, $toUser); + } catch (\InvalidArgumentException $e) { + $this->assertSame('User is not a member of the organization', $e->getMessage()); + } + } + public function test_change_ownership_changes_ownership_of_organization_to_new_user(): void { // Arrange @@ -63,13 +89,116 @@ public function test_change_ownership_changes_ownership_of_organization_to_new_u ]); // Act - /** @var UserService $userService */ - $userService = app(UserService::class); - $userService->changeOwnership($organization, $newOwner); + $this->userService->changeOwnership($organization, $newOwner); // Assert $this->assertSame($newOwner->getKey(), $organization->refresh()->user_id); $this->assertSame(Role::Owner->value, Member::whereBelongsTo($newOwner)->whereBelongsTo($organization)->firstOrFail()->role); $this->assertSame(Role::Admin->value, Member::whereBelongsTo($oldOwner)->whereBelongsTo($organization)->firstOrFail()->role); } + + public function test_change_ownership_fails_if_new_user_is_not_member_of_organization(): void + { + // Arrange + $organization = Organization::factory()->create(); + $newOwner = User::factory()->create(); + $oldOwner = User::factory()->create(); + $organization->users()->attach($oldOwner->getKey(), [ + 'role' => Role::Owner->value, + ]); + + // Act + try { + $this->userService->changeOwnership($organization, $newOwner); + } catch (\InvalidArgumentException $e) { + $this->assertSame('User is not a member of the organization', $e->getMessage()); + } + } + + public function test_make_member_to_placeholder_creates_new_user_based_on_member_and_changes_member_to_placeholder(): void + { + // Arrange + $user = User::factory()->create(); + $organization = Organization::factory()->create(); + $member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create(); + $timeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->create(); + $project = Project::factory()->forOrganization($organization)->create(); + $projectMember = ProjectMember::factory()->forProject($project)->forMember($member)->create(); + // Note: create other user, organization, member, time entry and project member to check that they are not changed + $otherUser = User::factory()->create(); + $otherOrganization = Organization::factory()->create(); + $otherMember = Member::factory()->forOrganization($otherOrganization)->forUser($otherUser)->role(Role::Employee)->create(); + $otherTimeEntry = TimeEntry::factory()->forOrganization($otherOrganization)->forMember($otherMember)->create(); + $otherProject = Project::factory()->forOrganization($otherOrganization)->create(); + $otherProjectMember = ProjectMember::factory()->forProject($otherProject)->forMember($otherMember)->create(); + + // Act + $this->userService->makeMemberToPlaceholder($member); + + // Assert + $member->refresh(); + $timeEntry->refresh(); + $projectMember->refresh(); + $placeholderUser = $member->user; + $this->assertTrue($placeholderUser->is_placeholder); + $this->assertSame(Role::Placeholder->value, $member->role); + $this->assertSame($organization->getKey(), $member->organization_id); + $this->assertSame($placeholderUser->getKey(), $projectMember->user_id); + $this->assertSame($member->getKey(), $projectMember->member_id); + $this->assertSame($placeholderUser->getKey(), $timeEntry->user_id); + $this->assertSame($member->getKey(), $timeEntry->member_id); + $this->assertSame(1, $user->organizations()->count()); + // Note: check that other user did not change + $otherMember->refresh(); + $otherTimeEntry->refresh(); + $otherProjectMember->refresh(); + $otherUser->refresh(); + $this->assertFalse($otherUser->is_placeholder); + $this->assertSame(Role::Employee->value, $otherMember->role); + $this->assertSame($otherOrganization->getKey(), $otherMember->organization_id); + $this->assertSame($otherUser->getKey(), $otherProjectMember->user_id); + $this->assertSame($otherMember->getKey(), $otherProjectMember->member_id); + $this->assertSame($otherUser->getKey(), $otherTimeEntry->user_id); + $this->assertSame($otherMember->getKey(), $otherTimeEntry->member_id); + $this->assertSame(1, $otherUser->organizations()->count()); + } + + public function test_make_sure_user_has_current_organization_sets_current_organization_for_user_if_null(): void + { + // Arrange + $user = User::factory()->create(); + $organization = Organization::factory()->create(); + $otherOrganization = Organization::factory()->create(); + Member::factory()->forUser($user)->forOrganization($organization)->create(); + $user->current_team_id = null; + $user->save(); + + // Act + $this->userService->makeSureUserHasCurrentOrganization($user); + + // Assert + $this->assertSame($organization->getKey(), $user->refresh()->currentOrganization->getKey()); + } + + public function make_sure_user_has_at_least_one_organization_creates_organization_for_user_if_there_are_not_member_of_one(): void + { + // Arrange + $user = User::factory()->create(); + $organization = Organization::factory()->create(); + + // Act + $this->userService->makeSureUserHasAtLeastOneOrganization($user); + + // Assert + $user->refresh(); + $this->assertSame(1, $user->organizations()->count()); + $newOrganization = $user->organizations()->first(); + $this->assertNotSame($organization->getKey(), $newOrganization->getKey()); + $this->assertSame($user->name."'s Organization", $newOrganization->name); + $this->assertTrue($newOrganization->personal_team); + $this->assertSame($user->getKey(), $newOrganization->user_id); + $newMember = Member::whereBelongsTo($user)->whereBelongsTo($newOrganization)->firstOrFail(); + $this->assertSame(Role::Owner->value, $newMember->role); + $this->assertSame($newOrganization->getKey(), $user->currentOrganization->getKey()); + } }