Persist system settings in database

This commit is contained in:
Arya Dwi Putra
2025-12-01 07:53:01 +07:00
parent 9aab35ff05
commit 5dd9ff7240
8 changed files with 246 additions and 14 deletions

View File

@@ -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=

49
app/Models/Setting.php Normal file
View File

@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
use HasFactory;
protected $fillable = [
'key',
'group',
'type',
'value',
'description',
'is_public',
];
/**
* Setter untuk menyimpan nilai dengan aman (array -> 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,
};
}
}

View File

@@ -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';
}
}

View File

@@ -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'),
],
];

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('settings', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Database\Seeders;
use App\Models\Setting;
use Illuminate\Database\Seeder;
class SettingSeeder extends Seeder
{
/**
* Seed awal agar sistem langsung bisa dikustomisasi.
*/
public function run(): void
{
$settings = [
// Aplikasi & UI
['key' => '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);
}
}
}

View File

@@ -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'));
}
}