From 490742243d0786b888879ad1ea39b880eb7f9344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omer=20=C5=A0abi=C4=87?= Date: Thu, 26 Jun 2025 12:08:39 +0200 Subject: [PATCH 1/2] feat: implement user settings --- config/settings.php | 6 + ...2025_06_23_110245_create_user_settings.php | 47 +++++ ...2025_06_23_115758_create_user_settings.php | 14 ++ src/Filament/Pages/ManageUserSettings.php | 40 ++++ src/Foundation/Settings/IsUserSiteScoped.php | 57 ++++++ src/Models/User.php | 7 + .../UserSiteSettingsRepository.php | 82 +++++++++ src/Settings/UserSettings.php | 20 ++ .../UserSettingsRepositoryTest.php | 173 ++++++++++++++++++ 9 files changed, 446 insertions(+) create mode 100644 database/migrations/2025_06_23_110245_create_user_settings.php create mode 100644 database/settings/2025_06_23_115758_create_user_settings.php create mode 100644 src/Filament/Pages/ManageUserSettings.php create mode 100644 src/Foundation/Settings/IsUserSiteScoped.php create mode 100644 src/Settings/Repositories/UserSiteSettingsRepository.php create mode 100644 src/Settings/UserSettings.php create mode 100644 tests/Unit/Settings/Repositories/UserSettingsRepositoryTest.php diff --git a/config/settings.php b/config/settings.php index 1b30f8a..4fabd51 100644 --- a/config/settings.php +++ b/config/settings.php @@ -40,6 +40,12 @@ 'table' => null, 'connection' => null, ], + 'user_tenant' => [ + 'type' => \Eclipse\Core\Settings\Repositories\UserSiteSettingsRepository::class, + 'model' => null, + 'table' => 'user_site_settings', + 'connection' => null, + ], 'redis' => [ 'type' => Spatie\LaravelSettings\SettingsRepositories\RedisSettingsRepository::class, 'connection' => null, diff --git a/database/migrations/2025_06_23_110245_create_user_settings.php b/database/migrations/2025_06_23_110245_create_user_settings.php new file mode 100644 index 0000000..05ba30b --- /dev/null +++ b/database/migrations/2025_06_23_110245_create_user_settings.php @@ -0,0 +1,47 @@ +id(); + + $table->foreignId('user_id') + ->nullable() + ->constrained('users') + ->cascadeOnDelete() + ->cascadeOnUpdate(); + + $table->foreignId('site_id') + ->nullable() + ->constrained('sites') + ->cascadeOnDelete() + ->cascadeOnUpdate(); + + $table->string('group'); + $table->string('name'); + $table->boolean('locked')->default(false); + $table->json('payload'); + + $table->timestamps(); + + $table->unique(['group', 'name', 'user_id', 'site_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_site_settings'); + } +}; diff --git a/database/settings/2025_06_23_115758_create_user_settings.php b/database/settings/2025_06_23_115758_create_user_settings.php new file mode 100644 index 0000000..a0d9bbc --- /dev/null +++ b/database/settings/2025_06_23_115758_create_user_settings.php @@ -0,0 +1,14 @@ +migrator->repository('user_tenant'); + + $this->migrator->add('site.outgoing_email_address', ''); + $this->migrator->add('site.outgoing_email_signature', ''); + } +}; diff --git a/src/Filament/Pages/ManageUserSettings.php b/src/Filament/Pages/ManageUserSettings.php new file mode 100644 index 0000000..055d6df --- /dev/null +++ b/src/Filament/Pages/ManageUserSettings.php @@ -0,0 +1,40 @@ +schema([ + Components\Section::make('Email settings') + ->schema([ + Components\TextInput::make('outgoing_email_address') + ->email() + ->label('Outgoing email address'), + Components\RichEditor::make('outgoing_email_signature') + ->label('Outgoing email signature'), + ]), + ]); + } + + public static function getNavigationGroup(): ?string + { + return 'Configuration'; + } + + public static function getNavigationLabel(): string + { + return 'My settings'; + } +} diff --git a/src/Foundation/Settings/IsUserSiteScoped.php b/src/Foundation/Settings/IsUserSiteScoped.php new file mode 100644 index 0000000..7c9c353 --- /dev/null +++ b/src/Foundation/Settings/IsUserSiteScoped.php @@ -0,0 +1,57 @@ +getRepository(); + + // Make sure it's a UserSettingsRepository + if (!$repository instanceof UserSiteSettingsRepository) { + throw new RuntimeException('Repository must be an instance of UserSiteSettingsRepository'); + } + + // Configure the repository to use the specified user + $userRepository = $repository->forUser($userId); + + // Get the properties directly from the repository + $properties = collect($userRepository->getPropertiesInGroup(static::group())); + + // Process the properties (decrypt, cast, etc.) + $reflectionProperties = collect((new ReflectionClass(static::class))->getProperties(ReflectionProperty::IS_PUBLIC)) + ->mapWithKeys(fn (ReflectionProperty $property) => [$property->getName() => $property]); + + // Set the properties on the settings instance + foreach ($reflectionProperties as $name => $property) { + if (isset($properties[$name])) { + $settings->$name = $properties[$name]; + } elseif ($property->hasDefaultValue()) { + $settings->$name = $property->getDefaultValue(); + } + } + + return $settings; + } +} diff --git a/src/Models/User.php b/src/Models/User.php index 5d99bd5..18b2df9 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -3,6 +3,7 @@ namespace Eclipse\Core\Models; use Eclipse\Core\Database\Factories\UserFactory; +use Eclipse\Core\Settings\UserSettings; use Exception; use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\HasAvatar; @@ -14,6 +15,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Collection; +use Spatie\LaravelSettings\Settings; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\Permission\Traits\HasRoles; @@ -158,4 +160,9 @@ public function canImpersonate(): bool { return $this->can('impersonate', User::class); } + + public function getSettings(string $settingsClass = UserSettings::class): Settings + { + return $settingsClass::forUser($this->id); + } } diff --git a/src/Settings/Repositories/UserSiteSettingsRepository.php b/src/Settings/Repositories/UserSiteSettingsRepository.php new file mode 100644 index 0000000..d962931 --- /dev/null +++ b/src/Settings/Repositories/UserSiteSettingsRepository.php @@ -0,0 +1,82 @@ +map(function ($payload, $name) use ($group) { + return [ + 'group' => $group, + 'name' => $name, + 'payload' => $this->encode($payload), + 'site_id' => Filament::getTenant()?->id, + 'user_id' => auth()->user()?->id, + ]; + })->values()->toArray(); + + $this->getBuilder(false) + ->where('group', $group) + ->upsert($propertiesInBatch, ['group', 'name', 'site_id', 'user_id'], ['payload']); + } + + public function forUser(int $userId): self + { + $clone = clone $this; + $clone->userId = $userId; + return $clone; + } + + public function getBuilder(bool $fallback = true): Builder + { + $builder = parent::getBuilder(); + $userId = $this->userId ?? auth()->user()?->id; + + if ($fallback) { + // Use default fallback + $table = $this->table ?? (new SettingsProperty)->getTable(); + $builder + ->where(function (Builder $query) use ($table, $userId) { + $query + ->where(function (Builder $query) use ($table, $userId) { + $query + // ... where site_id matches + ->where('site_id', Filament::getTenant()?->id) + // ... where user_id matches + ->where('user_id', $userId); + }) + // ... or where site_id is null and a record with a matching site_id does not exist + ->orWhere(function (Builder $query) use ($table, $userId) { + $query + ->whereNull('site_id') + ->whereNull('user_id') + ->whereNotExists(function (QueryBuilder $query) use ($table, $userId) { + $query->select(DB::raw(1)) + ->from($table, 't2') + ->where('site_id', Filament::getTenant()?->id) + ->where('user_id', $userId) + ->whereColumn('t2.group', $table.'.group') + ->whereColumn('t2.name', $table.'.name'); + }); + }); + }); + } else { + // Don't use fallback, get only settings with the exact site/user match + $builder + ->where('site_id', Filament::getTenant()?->id) + ->where('user_id', $userId); + } + + return $builder; + } +} diff --git a/src/Settings/UserSettings.php b/src/Settings/UserSettings.php new file mode 100644 index 0000000..159fbc7 --- /dev/null +++ b/src/Settings/UserSettings.php @@ -0,0 +1,20 @@ +set_up_common_user_and_tenant(); +}); + +test('default user setting value is used when user has no settings yet', function () { + // Get the default settings (where user_id and site_id are null) + $defaultSettings = app(UserSettings::class); + + // Verify that the settings are loaded from the default values + $this->assertEquals('', $defaultSettings->outgoing_email_address); + $this->assertEquals('', $defaultSettings->outgoing_email_signature); + + // Verify that no user-specific settings exist in the database + $userSettings = DB::table('user_site_settings') + ->where('user_id', $this->user->id) + ->where('site_id', Filament::getTenant()->id) + ->get(); + + $this->assertCount(0, $userSettings); +}); + +test('user settings are saved with correct user_id and site_id', function () { + // Get the settings instance + $settings = app(UserSettings::class); + + // Update the settings + $settings->outgoing_email_address = 'test@example.com'; + $settings->outgoing_email_signature = '

Test Signature

'; + $settings->save(); + + // Verify that the settings were saved with the correct user_id and site_id + $savedSettings = DB::table('user_site_settings') + ->where('user_id', $this->user->id) + ->where('site_id', Filament::getTenant()->id) + ->get(); + + $this->assertCount(2, $savedSettings); // Two settings: email address and signature + + // Verify the values were saved correctly + $emailSetting = $savedSettings->where('name', 'outgoing_email_address')->first(); + $signatureSetting = $savedSettings->where('name', 'outgoing_email_signature')->first(); + + $this->assertNotNull($emailSetting); + $this->assertNotNull($signatureSetting); + $this->assertEquals(json_encode('test@example.com'), $emailSetting->payload); + $this->assertEquals(json_encode('

Test Signature

'), $signatureSetting->payload); +}); + +test('user-specific settings are loaded instead of defaults', function () { + // First, save user-specific settings + $settings = app(UserSettings::class); + $settings->outgoing_email_address = 'user@example.com'; + $settings->outgoing_email_signature = '

User Signature

'; + $settings->save(); + + // Now, get a fresh instance of the settings + $freshSettings = app(UserSettings::class); + + // Verify that the user-specific settings are loaded + $this->assertEquals('user@example.com', $freshSettings->outgoing_email_address); + $this->assertEquals('

User Signature

', $freshSettings->outgoing_email_signature); +}); + +test('site-specific settings are not used on other sites', function () { + // Create a second site + $secondSite = Site::factory()->create(); + + // Save settings for the current site + $settings = app(UserSettings::class); + $settings->outgoing_email_address = 'site1@example.com'; + $settings->outgoing_email_signature = '

Site 1 Signature

'; + $settings->save(); + + // Switch to the second site + $originalSite = Filament::getTenant(); + Filament::setTenant($secondSite); + + // Get settings for the second site + $secondSiteSettings = app(UserSettings::class); + + // Verify that the second site uses default settings, not the first site's settings + $this->assertEquals('', $secondSiteSettings->outgoing_email_address); + $this->assertEquals('', $secondSiteSettings->outgoing_email_signature); + + // Restore the original site + Filament::setTenant($originalSite); + + // Test settings again for the first site + $settings->refresh(); + $this->assertEquals('site1@example.com', $settings->outgoing_email_address); + $this->assertEquals('

Site 1 Signature

', $settings->outgoing_email_signature); +}); + +test('user-specific settings are not used for other users', function () { + // Save settings for the current user + $settings = app(UserSettings::class); + $settings->outgoing_email_address = 'user1@example.com'; + $settings->outgoing_email_signature = '

User 1 Signature

'; + $settings->save(); + + // Create and switch to a second user + $secondUser = User::factory()->create(); + $secondUser->sites()->attach(Filament::getTenant()); + + Auth::login($secondUser); + + // Get settings for the second user + $secondUserSettings = app(UserSettings::class); + + // Verify that the second user uses default settings, not the first user's settings + $this->assertEquals('', $secondUserSettings->outgoing_email_address); + $this->assertEquals('', $secondUserSettings->outgoing_email_signature); +}); + +test('forUser method fetches settings for a specific user', function () { + // Save settings for the current user + $settings = app(UserSettings::class); + $settings->outgoing_email_address = 'user1@example.com'; + $settings->outgoing_email_signature = '

User 1 Signature

'; + $settings->save(); + + // Create a second user + $secondUser = User::factory()->create(); + $secondUser->sites()->attach(Filament::getTenant()); + + // Save settings for the second user + Auth::login($secondUser); + $secondUserSettings = app(UserSettings::class); + $secondUserSettings->outgoing_email_address = 'user2@example.com'; + $secondUserSettings->outgoing_email_signature = '

User 2 Signature

'; + $secondUserSettings->save(); + + // Switch back to the first user + Auth::login($this->user); + + // Use forUser to get settings for the second user while authenticated as the first user + $fetchedSecondUserSettings = UserSettings::forUser($secondUser->id); + + // Verify that the fetched settings match the second user's settings + $this->assertEquals('user2@example.com', $fetchedSecondUserSettings->outgoing_email_address); + $this->assertEquals('

User 2 Signature

', $fetchedSecondUserSettings->outgoing_email_signature); + + // Verify that the current user's settings are still accessible + $currentUserSettings = app(UserSettings::class); + $this->assertEquals('user1@example.com', $currentUserSettings->outgoing_email_address); + $this->assertEquals('

User 1 Signature

', $currentUserSettings->outgoing_email_signature); +}); + +test('forUser method works in non-user context', function () { + // Set settings for the current user + $userSettings = app(UserSettings::class); + $userSettings->outgoing_email_address = 'user@example.com'; + $userSettings->outgoing_email_signature = '

User Signature

'; + $userSettings->save(); + + // Log out and fetch the settings + Auth::logout(); + $settings = $this->user->getSettings(); + $this->assertInstanceOf(UserSettings::class, $settings); + + // Assert values + $this->assertEquals('user@example.com', $settings->outgoing_email_address); + $this->assertEquals('

User Signature

', $settings->outgoing_email_signature); +}); From ff7c3483dde4b3d7a9235eb20bc597cbdd5096c8 Mon Sep 17 00:00:00 2001 From: SlimDeluxe <131700+SlimDeluxe@users.noreply.github.com> Date: Thu, 26 Jun 2025 10:09:03 +0000 Subject: [PATCH 2/2] style: fix code style --- src/Foundation/Settings/IsUserSiteScoped.php | 7 ++- .../UserSiteSettingsRepository.php | 47 ++++++++++--------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/Foundation/Settings/IsUserSiteScoped.php b/src/Foundation/Settings/IsUserSiteScoped.php index 7c9c353..bba10d9 100644 --- a/src/Foundation/Settings/IsUserSiteScoped.php +++ b/src/Foundation/Settings/IsUserSiteScoped.php @@ -17,19 +17,18 @@ public static function repository(): ?string /** * Get settings for a specific user * - * @param int $userId The ID of the user to get settings for - * @return static + * @param int $userId The ID of the user to get settings for */ public static function forUser(int $userId): static { // Create a new instance of UserSettings - $settings = new static(); + $settings = new static; // Get the repository from the settings instance $repository = $settings->getRepository(); // Make sure it's a UserSettingsRepository - if (!$repository instanceof UserSiteSettingsRepository) { + if (! $repository instanceof UserSiteSettingsRepository) { throw new RuntimeException('Repository must be an instance of UserSiteSettingsRepository'); } diff --git a/src/Settings/Repositories/UserSiteSettingsRepository.php b/src/Settings/Repositories/UserSiteSettingsRepository.php index d962931..c3061c3 100644 --- a/src/Settings/Repositories/UserSiteSettingsRepository.php +++ b/src/Settings/Repositories/UserSiteSettingsRepository.php @@ -34,6 +34,7 @@ public function forUser(int $userId): self { $clone = clone $this; $clone->userId = $userId; + return $clone; } @@ -47,29 +48,29 @@ public function getBuilder(bool $fallback = true): Builder $table = $this->table ?? (new SettingsProperty)->getTable(); $builder ->where(function (Builder $query) use ($table, $userId) { - $query - ->where(function (Builder $query) use ($table, $userId) { - $query - // ... where site_id matches - ->where('site_id', Filament::getTenant()?->id) - // ... where user_id matches - ->where('user_id', $userId); - }) - // ... or where site_id is null and a record with a matching site_id does not exist - ->orWhere(function (Builder $query) use ($table, $userId) { - $query - ->whereNull('site_id') - ->whereNull('user_id') - ->whereNotExists(function (QueryBuilder $query) use ($table, $userId) { - $query->select(DB::raw(1)) - ->from($table, 't2') - ->where('site_id', Filament::getTenant()?->id) - ->where('user_id', $userId) - ->whereColumn('t2.group', $table.'.group') - ->whereColumn('t2.name', $table.'.name'); - }); - }); - }); + $query + ->where(function (Builder $query) use ($userId) { + $query + // ... where site_id matches + ->where('site_id', Filament::getTenant()?->id) + // ... where user_id matches + ->where('user_id', $userId); + }) + // ... or where site_id is null and a record with a matching site_id does not exist + ->orWhere(function (Builder $query) use ($table, $userId) { + $query + ->whereNull('site_id') + ->whereNull('user_id') + ->whereNotExists(function (QueryBuilder $query) use ($table, $userId) { + $query->select(DB::raw(1)) + ->from($table, 't2') + ->where('site_id', Filament::getTenant()?->id) + ->where('user_id', $userId) + ->whereColumn('t2.group', $table.'.group') + ->whereColumn('t2.name', $table.'.name'); + }); + }); + }); } else { // Don't use fallback, get only settings with the exact site/user match $builder