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..bba10d9 --- /dev/null +++ b/src/Foundation/Settings/IsUserSiteScoped.php @@ -0,0 +1,56 @@ +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..c3061c3 --- /dev/null +++ b/src/Settings/Repositories/UserSiteSettingsRepository.php @@ -0,0 +1,83 @@ +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 ($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); +});