From 5dd9ff72409915d11a6e8d4a8e73281f55791bbf Mon Sep 17 00:00:00 2001 From: Arya Dwi Putra Date: Mon, 1 Dec 2025 07:53:01 +0700 Subject: [PATCH] Persist system settings in database --- .env.example | 7 +++ app/Models/Setting.php | 49 +++++++++++++++ app/Services/System/SystemSettingService.php | 62 +++++++++++++++++-- config/system.php | 11 ++++ ...025_12_02_000000_create_settings_table.php | 27 ++++++++ database/seeders/DatabaseSeeder.php | 3 + database/seeders/SettingSeeder.php | 47 ++++++++++++++ tests/Unit/SystemSettingServiceTest.php | 54 +++++++++++++--- 8 files changed, 246 insertions(+), 14 deletions(-) create mode 100644 app/Models/Setting.php create mode 100644 database/migrations/2025_12_02_000000_create_settings_table.php create mode 100644 database/seeders/SettingSeeder.php diff --git a/.env.example b/.env.example index 39cefaa..16974b4 100644 --- a/.env.example +++ b/.env.example @@ -62,10 +62,17 @@ MAIL_FROM_NAME="${APP_NAME}" ASSET_CODE_PREFIX=AST ASSET_QR_ENABLED=true ASSET_WARRANTY_REMINDER_DAYS=30 +ASSET_DEPRECIATION_METHOD=straight_line +ASSET_ATTACHMENT_MAX_SIZE_MB=20 SYSTEM_DATE_FORMAT=d/m/Y SYSTEM_TIME_FORMAT=H:i SYSTEM_DEFAULT_CURRENCY=IDR SYSTEM_AUDIT_LOG=true +SYSTEM_TABLE_PAGE_SIZE=25 +NOTIFICATION_EMAIL_ENABLED=true +NOTIFICATION_SLACK_WEBHOOK_URL= +MAINTENANCE_READONLY_MODE=false +MAINTENANCE_WINDOW= AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= diff --git a/app/Models/Setting.php b/app/Models/Setting.php new file mode 100644 index 0000000..18c0e91 --- /dev/null +++ b/app/Models/Setting.php @@ -0,0 +1,49 @@ + json). + */ + public function setValueAttribute($value): void + { + if (is_array($value)) { + $this->attributes['value'] = json_encode($value); + $this->attributes['type'] = $this->attributes['type'] ?? 'array'; + + return; + } + + $this->attributes['value'] = (string) $value; + } + + /** + * Getter untuk casting nilai sesuai tipe yang disimpan. + */ + public function getValueAttribute($value): mixed + { + return match ($this->type) { + 'bool', 'boolean' => filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false, + 'int', 'integer' => (int) $value, + 'float', 'double' => (float) $value, + 'array', 'json' => json_decode($value, true) ?? [], + default => $value, + }; + } +} diff --git a/app/Services/System/SystemSettingService.php b/app/Services/System/SystemSettingService.php index 121717d..baf121c 100644 --- a/app/Services/System/SystemSettingService.php +++ b/app/Services/System/SystemSettingService.php @@ -3,15 +3,23 @@ namespace App\Services\System; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Cache; +use App\Models\Setting; class SystemSettingService { + private const CACHE_KEY = 'system.settings'; + /** * Ambil semua konfigurasi sistem dengan struktur terkelompok. */ public function all(): array { - return config('system', []); + $defaults = config('system', []); + $overrides = Cache::rememberForever(self::CACHE_KEY, fn () => $this->loadFromDatabase()); + + // Override nilai default dengan data database agar dapat full customize. + return array_replace_recursive($defaults, $overrides); } /** @@ -25,11 +33,55 @@ class SystemSettingService /** * Set konfigurasi secara runtime tanpa menyentuh database. */ - public function set(string $key, mixed $value): void + public function set(string $key, mixed $value, ?string $type = null, ?string $description = null, ?string $group = null, bool $isPublic = false): void { - $settings = $this->all(); - Arr::set($settings, $key, $value); + Setting::updateOrCreate( + ['key' => $key], + [ + 'group' => $group ?? $this->guessGroupFromKey($key), + 'type' => $type ?? $this->inferType($value), + 'value' => $value, + 'description' => $description, + 'is_public' => $isPublic, + ] + ); - config(['system' => $settings]); + $this->refresh(); + } + + /** + * Refresh cache & config setelah update agar konsisten. + */ + public function refresh(): void + { + Cache::forget(self::CACHE_KEY); + config(['system' => $this->all()]); + } + + private function loadFromDatabase(): array + { + $settings = []; + + Setting::query()->orderBy('key')->get()->each(function (Setting $setting) use (&$settings) { + Arr::set($settings, $setting->key, $setting->value); + }); + + return $settings; + } + + private function inferType(mixed $value): string + { + return match (true) { + is_bool($value) => 'boolean', + is_int($value) => 'integer', + is_float($value) => 'float', + is_array($value) => 'array', + default => 'string', + }; + } + + private function guessGroupFromKey(string $key): string + { + return explode('.', $key)[0] ?? 'general'; } } diff --git a/config/system.php b/config/system.php index 6c8c544..bc42ded 100644 --- a/config/system.php +++ b/config/system.php @@ -10,13 +10,24 @@ return [ 'code_prefix' => env('ASSET_CODE_PREFIX', 'AST'), 'qr_enabled' => env('ASSET_QR_ENABLED', true), 'warranty_reminder_days' => env('ASSET_WARRANTY_REMINDER_DAYS', 30), + 'depreciation_method' => env('ASSET_DEPRECIATION_METHOD', 'straight_line'), + 'attachment_max_size_mb' => env('ASSET_ATTACHMENT_MAX_SIZE_MB', 20), ], 'ui' => [ 'date_format' => env('SYSTEM_DATE_FORMAT', 'd/m/Y'), 'time_format' => env('SYSTEM_TIME_FORMAT', 'H:i'), 'currency' => env('SYSTEM_DEFAULT_CURRENCY', 'IDR'), + 'table_page_size' => env('SYSTEM_TABLE_PAGE_SIZE', 25), ], 'security' => [ 'audit_log' => env('SYSTEM_AUDIT_LOG', true), ], + 'notification' => [ + 'email_enabled' => env('NOTIFICATION_EMAIL_ENABLED', true), + 'slack_webhook_url' => env('NOTIFICATION_SLACK_WEBHOOK_URL'), + ], + 'maintenance' => [ + 'readonly_mode' => env('MAINTENANCE_READONLY_MODE', false), + 'window' => env('MAINTENANCE_WINDOW'), + ], ]; diff --git a/database/migrations/2025_12_02_000000_create_settings_table.php b/database/migrations/2025_12_02_000000_create_settings_table.php new file mode 100644 index 0000000..750ea96 --- /dev/null +++ b/database/migrations/2025_12_02_000000_create_settings_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('key')->unique(); + $table->string('group')->nullable(); + $table->string('type')->default('string'); + $table->text('value')->nullable(); + $table->string('description')->nullable(); + $table->boolean('is_public')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('settings'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef..64742f4 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -19,5 +19,8 @@ class DatabaseSeeder extends Seeder 'name' => 'Test User', 'email' => 'test@example.com', ]); + + // Seed setting default agar siap dikustomisasi. + $this->call(SettingSeeder::class); } } diff --git a/database/seeders/SettingSeeder.php b/database/seeders/SettingSeeder.php new file mode 100644 index 0000000..3a694bb --- /dev/null +++ b/database/seeders/SettingSeeder.php @@ -0,0 +1,47 @@ + 'application.name', 'value' => 'Asset Management System', 'type' => 'string', 'group' => 'application', 'description' => 'Nama aplikasi yang tampil di UI'], + ['key' => 'application.timezone', 'value' => 'Asia/Jakarta', 'type' => 'string', 'group' => 'application'], + ['key' => 'ui.date_format', 'value' => 'd/m/Y', 'type' => 'string', 'group' => 'ui'], + ['key' => 'ui.time_format', 'value' => 'H:i', 'type' => 'string', 'group' => 'ui'], + ['key' => 'ui.currency', 'value' => 'IDR', 'type' => 'string', 'group' => 'ui'], + ['key' => 'ui.table_page_size', 'value' => 25, 'type' => 'integer', 'group' => 'ui', 'description' => 'Default jumlah baris tabel per halaman'], + + // Aset + ['key' => 'asset.code_prefix', 'value' => 'AST', 'type' => 'string', 'group' => 'asset'], + ['key' => 'asset.qr_enabled', 'value' => true, 'type' => 'boolean', 'group' => 'asset'], + ['key' => 'asset.warranty_reminder_days', 'value' => 30, 'type' => 'integer', 'group' => 'asset'], + ['key' => 'asset.depreciation_method', 'value' => 'straight_line', 'type' => 'string', 'group' => 'asset'], + ['key' => 'asset.attachment_max_size_mb', 'value' => 20, 'type' => 'integer', 'group' => 'asset', 'description' => 'Batas upload lampiran aset (MB)'], + + // Keamanan & audit + ['key' => 'security.audit_log', 'value' => true, 'type' => 'boolean', 'group' => 'security', 'description' => 'Aktifkan audit log aktivitas'], + + // Notifikasi + ['key' => 'notification.email_enabled', 'value' => true, 'type' => 'boolean', 'group' => 'notification'], + ['key' => 'notification.slack_webhook_url', 'value' => null, 'type' => 'string', 'group' => 'notification'], + + // Maintenance + ['key' => 'maintenance.readonly_mode', 'value' => false, 'type' => 'boolean', 'group' => 'maintenance', 'description' => 'Jika true, seluruh fitur tulis dimatikan'], + ['key' => 'maintenance.window', 'value' => null, 'type' => 'string', 'group' => 'maintenance', 'description' => 'Jadwal maintenance terencana'], + ]; + + foreach ($settings as $setting) { + Setting::updateOrCreate(['key' => $setting['key']], $setting); + } + } +} diff --git a/tests/Unit/SystemSettingServiceTest.php b/tests/Unit/SystemSettingServiceTest.php index 2baf04f..3c3d06e 100644 --- a/tests/Unit/SystemSettingServiceTest.php +++ b/tests/Unit/SystemSettingServiceTest.php @@ -2,33 +2,69 @@ namespace Tests\Unit; +use App\Models\Setting; use App\Services\System\SystemSettingService; +use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class SystemSettingServiceTest extends TestCase { - public function test_it_reads_system_settings_from_config(): void + use RefreshDatabase; + + public function test_it_reads_config_when_database_empty(): void { - // Override konfigurasi untuk memastikan service mengambil nilai terbaru. config([ - 'system.asset.code_prefix' => 'ASTX', + 'system.asset.code_prefix' => 'CFG', 'system.application.name' => 'Custom AMS', ]); $service = app(SystemSettingService::class); - $this->assertSame('ASTX', $service->get('asset.code_prefix')); + $this->assertSame('CFG', $service->get('asset.code_prefix')); $this->assertSame('Custom AMS', $service->get('application.name')); - $this->assertTrue($service->get('asset.qr_enabled')); } - public function test_it_can_override_runtime_settings(): void + public function test_it_prefers_database_overrides_and_casts_types(): void + { + // Simulasikan override via DB. + Setting::create([ + 'key' => 'asset.qr_enabled', + 'value' => false, + 'type' => 'boolean', + 'group' => 'asset', + ]); + + Setting::create([ + 'key' => 'ui.table_page_size', + 'value' => 50, + 'type' => 'integer', + 'group' => 'ui', + ]); + + $service = app(SystemSettingService::class); + + // config default = true, namun DB override -> false + $this->assertFalse($service->get('asset.qr_enabled')); + $this->assertSame(50, $service->get('ui.table_page_size')); + } + + public function test_it_sets_and_refreshes_cache_and_config(): void { $service = app(SystemSettingService::class); - $service->set('ui.date_format', 'Y-m-d'); + // Panggilan awal untuk memicu cache default. + $this->assertFalse($service->get('maintenance.readonly_mode')); - $this->assertSame('Y-m-d', $service->get('ui.date_format')); - $this->assertSame('Y-m-d', config('system.ui.date_format')); + // Ubah via service, pastikan DB dan cache/config ter-update. + $service->set('maintenance.readonly_mode', true); + + $this->assertDatabaseHas('settings', [ + 'key' => 'maintenance.readonly_mode', + 'value' => '1', + 'type' => 'boolean', + ]); + + $this->assertTrue($service->get('maintenance.readonly_mode')); + $this->assertTrue(config('system.maintenance.readonly_mode')); } }