feat: add app and database modules

This commit is contained in:
2026-05-21 16:05:11 +07:00
parent 37b7e783f5
commit fad70d096b
212 changed files with 23901 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Services\SystemConfig;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class SettingFileUploader
{
private const FIXED_PATHS = [
'app_logo' => 'assets/img/logo.png',
'app_favicon' => 'assets/img/favicon.png',
'maintenance_mode_image' => 'assets/img/maintenance.png',
];
public function replace(string $key, UploadedFile $file, mixed $oldValue): ?string
{
$dir = public_path('assets/img');
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
if (isset(self::FIXED_PATHS[$key])) {
$filename = basename(self::FIXED_PATHS[$key]);
$file->move($dir, $filename);
return self::FIXED_PATHS[$key];
}
return $file->store('uploads/settings', 'public');
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Services\SystemConfig;
class SettingValueCaster
{
public static function normalize(mixed $value, string $type): mixed
{
return match ($type) {
'bool' => filter_var($value, FILTER_VALIDATE_BOOL),
'int' => $value === null || $value === '' ? null : (int) $value,
'float' => $value === null || $value === '' ? null : (float) $value,
'json' => is_array($value) ? $value : (json_decode((string) $value, true) ?: []),
'image_path' => is_string($value) ? trim($value) : null,
default => $value === null ? null : trim((string) $value),
};
}
public static function serialize(mixed $value): ?string
{
if ($value === null) {
return null;
}
if (is_bool($value)) {
return $value ? '1' : '0';
}
if (is_array($value)) {
return json_encode($value, JSON_UNESCAPED_SLASHES);
}
return (string) $value;
}
public static function deserialize(?string $value, string $type): mixed
{
if ($value === null) {
return null;
}
return match ($type) {
'bool' => $value === '1',
'int' => (int) $value,
'float' => (float) $value,
'json' => json_decode($value, true) ?: [],
default => $value,
};
}
public static function isUnchanged(mixed $oldValue, mixed $newValue): bool
{
return self::serialize($oldValue) === self::serialize($newValue);
}
}
@@ -0,0 +1,225 @@
<?php
/**
* ============================================================
*
* @project biiproject
*
* @author Andika Debi Putra
*
* @email andikadebiputra@gmail.com
*
* @website https://biiproject.com
*
* @copyright Copyright (c) 2026 Andika Debi Putra
* @license Proprietary - All Rights Reserved
*
* @version 1.0.0
*
* @created 2026-05-01
* ============================================================
*
* Unauthorized copying, modification, distribution, or use
* of this file is strictly prohibited without prior written
* permission from the author.
* ============================================================
*/
namespace App\Services\SystemConfig;
use App\Models\SystemSettingRevision;
use App\Repositories\SystemSettingRepository;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class SystemConfigService
{
private const CACHE_KEY = 'system_settings.all';
private const PUBLIC_CACHE_KEY = 'system_settings.public';
private const CACHE_TTL_MINUTES = 60;
protected static ?array $resolvedSettings = null;
public function __construct(
protected SystemSettingRepository $repository,
protected SettingFileUploader $fileUploader = new SettingFileUploader,
) {}
public static function definitions(): array
{
return SettingDefinitions::ALL;
}
public function all(bool $forceRefresh = false): array
{
if ($forceRefresh) {
$this->invalidateCache();
}
if (self::$resolvedSettings !== null) {
return self::$resolvedSettings;
}
try {
return self::$resolvedSettings = Cache::remember(
self::CACHE_KEY,
now()->addMinutes(self::CACHE_TTL_MINUTES),
fn () => $this->resolveAll(),
);
} catch (\Exception) {
return self::$resolvedSettings = $this->resolveAll();
}
}
public function getPublicSettings(): array
{
try {
return Cache::remember(
self::PUBLIC_CACHE_KEY,
now()->addMinutes(self::CACHE_TTL_MINUTES),
fn () => $this->buildPublicSettings(),
);
} catch (\Exception) {
return $this->buildPublicSettings();
}
}
public function grouped(): array
{
$all = $this->all();
$grouped = [];
foreach (SettingDefinitions::ALL as $key => $meta) {
$grouped[$meta['group']][$key] = $all[$key] ?? $meta['default'] ?? null;
}
return $grouped;
}
public function get(string $key, mixed $default = null): mixed
{
if (! array_key_exists($key, SettingDefinitions::ALL)) {
return $default;
}
return $this->all()[$key] ?? $default;
}
public function update(array $input, array $files = [], ?int $actorId = null, ?Request $request = null): void
{
$current = $this->all();
foreach (SettingDefinitions::ALL as $key => $meta) {
$oldValue = $current[$key] ?? $meta['default'] ?? null;
$newValue = $this->resolveNewValue($key, $meta, $input, $files, $oldValue);
if (SettingValueCaster::isUnchanged($oldValue, $newValue)) {
continue;
}
$setting = $this->repository->upsert([
'key' => $key,
'value' => SettingValueCaster::serialize($newValue),
'type' => $meta['type'],
'group' => $meta['group'],
'is_public' => (bool) $meta['is_public'],
'description' => $meta['description'],
'created_by' => $actorId,
'updated_by' => $actorId,
]);
$this->writeRevision($setting->id, $key, $oldValue, $newValue, $actorId, $request);
}
$this->invalidateCache();
}
protected function resolveNewValue(string $key, array $meta, array $input, array $files, mixed $oldValue): mixed
{
if ($meta['type'] === 'image_path') {
return isset($files[$key])
? $this->fileUploader->replace($key, $files[$key], $oldValue)
: $oldValue;
}
return array_key_exists($key, $input)
? SettingValueCaster::normalize($input[$key], (string) $meta['type'])
: $oldValue;
}
protected function resolveAll(): array
{
try {
if (! $this->repository->tableExists()) {
return $this->definitionDefaults();
}
$rows = $this->repository->all();
} catch (\Exception) {
return $this->definitionDefaults();
}
$resolved = [];
foreach (SettingDefinitions::ALL as $key => $meta) {
$record = $rows->firstWhere('key', $key);
$resolved[$key] = $record
? SettingValueCaster::deserialize($record->value, (string) $record->type)
: ($meta['default'] ?? null);
}
return $resolved;
}
protected function buildPublicSettings(): array
{
$all = $this->all();
$public = [];
foreach (SettingDefinitions::ALL as $key => $meta) {
if (! $meta['is_public']) {
continue;
}
$public[$key] = $all[$key] ?? $meta['default'] ?? null;
}
return $public;
}
protected function definitionDefaults(): array
{
$defaults = [];
foreach (SettingDefinitions::ALL as $key => $meta) {
$defaults[$key] = $meta['default'] ?? null;
}
return $defaults;
}
protected function writeRevision(
int $settingId,
string $key,
mixed $oldValue,
mixed $newValue,
?int $actorId,
?Request $request,
): void {
SystemSettingRevision::query()->create([
'system_setting_id' => $settingId,
'key' => $key,
'old_value' => $oldValue === null ? null : json_encode($oldValue, JSON_UNESCAPED_SLASHES),
'new_value' => $newValue === null ? null : json_encode($newValue, JSON_UNESCAPED_SLASHES),
'changed_by' => $actorId,
'changed_ip' => $request?->ip(),
'changed_agent' => $request?->userAgent(),
'created_at' => now(),
]);
}
protected function invalidateCache(): void
{
Cache::forget(self::CACHE_KEY);
Cache::forget(self::PUBLIC_CACHE_KEY);
self::$resolvedSettings = null;
}
}