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
+149
View File
@@ -0,0 +1,149 @@
<?php
namespace App\Services\System;
use Illuminate\Support\Str;
class ActivityFormatter
{
/**
* Get a friendly name for the model.
*/
public static function getFriendlyModelName(?string $model): string
{
if (! $model) {
return 'System';
}
$className = class_basename($model);
// Custom mapping for specific models
$mapping = [
'SystemSetting' => 'System Config',
'User' => 'User Profile',
'Role' => 'Access Role',
'Permission' => 'Access Permission',
'MobileSetting' => 'Mobile Config',
];
return $mapping[$className] ?? Str::headline($className);
}
/**
* Get badge class for the event.
*/
public static function getEventBadgeClass(string $event): string
{
return match (strtolower($event)) {
'created' => 'text-bg-success',
'updated' => 'text-bg-warning',
'deleted' => 'text-bg-danger',
'restored' => 'text-bg-info',
'login', 'login_attempt' => 'text-bg-info',
'logout' => 'text-bg-secondary',
'password_changed', 'password reset' => 'text-bg-primary',
default => 'text-bg-theme-1',
};
}
/**
* Get icon for the event.
*/
public static function getEventIcon(string $event): string
{
return match (strtolower($event)) {
'created' => 'bi-plus-circle',
'updated' => 'bi-pencil-square',
'deleted' => 'bi-trash',
'restored' => 'bi-arrow-counterclockwise',
'login', 'login_attempt' => 'bi-box-arrow-in-right',
'logout' => 'bi-box-arrow-right',
'password_changed', 'password reset' => 'bi-key',
default => 'bi-info-circle',
};
}
/**
* Formats the changes between old and new properties.
*/
public static function formatChanges(array $properties): array
{
$old = $properties['old'] ?? [];
$new = $properties['attributes'] ?? [];
$changes = [];
// If it's a "created" event, show all attributes
if (empty($old) && ! empty($new)) {
foreach ($new as $key => $value) {
if (self::isSensitive($key)) {
continue;
}
$changes[] = [
'field' => Str::headline($key),
'old' => null,
'new' => self::formatValue($value),
];
}
return $changes;
}
// For updates, show only changed fields
foreach ($new as $key => $value) {
if (self::isSensitive($key)) {
continue;
}
$oldValue = $old[$key] ?? null;
// Loose comparison to handle type juggling from DB
if ($oldValue != $value) {
$changes[] = [
'field' => Str::headline($key),
'old' => self::formatValue($oldValue),
'new' => self::formatValue($value),
];
}
}
return $changes;
}
private static function isSensitive(string $key): bool
{
$sensitive = ['password', 'remember_token', 'secret', 'key', 'token', '2fa_secret'];
foreach ($sensitive as $s) {
if (str_contains(strtolower($key), $s)) {
return true;
}
}
return false;
}
private static function formatValue(mixed $value): string
{
if (is_null($value)) {
return 'NULL';
}
if (is_bool($value)) {
return $value ? 'TRUE' : 'FALSE';
}
if (is_array($value) || is_object($value)) {
return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
if ($value === '') {
return '[empty]';
}
// Truncate long values but keep it readable
if (is_string($value) && strlen($value) > 200) {
return substr($value, 0, 197).'...';
}
return (string) $value;
}
}
@@ -0,0 +1,550 @@
<?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\System;
use App\Models\User;
use App\Notifications\SystemManagementNotification;
use App\Services\Monitoring\MonitoringFormatter;
use App\Services\SystemConfig\SystemConfigService;
use Exception;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage;
use Spatie\Backup\BackupDestination\Backup;
use Spatie\Backup\BackupDestination\BackupDestinationFactory;
use Spatie\Backup\Config\Config as SpatieConfig;
use Spatie\Backup\Notifications\Notifications\BackupHasFailedNotification;
use Spatie\Backup\Notifications\Notifications\BackupWasSuccessfulNotification;
use Spatie\Backup\Notifications\Notifications\CleanupHasFailedNotification;
use Spatie\Backup\Notifications\Notifications\CleanupWasSuccessfulNotification;
use Spatie\Backup\Notifications\Notifications\HealthyBackupWasFoundNotification;
use Spatie\Backup\Notifications\Notifications\UnhealthyBackupWasFoundNotification;
use Symfony\Component\Process\Process;
class BackupManagementService
{
protected $configService;
public function __construct(SystemConfigService $configService)
{
$this->configService = $configService;
}
/**
* Apply database settings to Spatie Backup config at runtime.
*
* @param bool $skipNotifications Whether to skip setting up notifications (useful for manual UI backups)
* @param string|null $driver Optional driver to override the database setting
*/
public function applyDynamicConfig(bool $skipNotifications = false, ?string $driver = null)
{
$settings = $this->configService->all();
// 1. Storage Disks
$driver = $driver ?? get_setting('backup_db_driver', 'local');
Config::set('backup.backup.destination.disks', [$driver]);
Config::set('backup.monitor_backups.0.disks', [$driver]);
// Dynamic Cloud Storage Configuration
if ($driver === 'gdrive') {
Config::set('filesystems.disks.gdrive.clientId', $settings['gdrive_client_id'] ?? '');
Config::set('filesystems.disks.gdrive.clientSecret', $settings['gdrive_client_secret'] ?? '');
Config::set('filesystems.disks.gdrive.refreshToken', $settings['gdrive_refresh_token'] ?? '');
Config::set('filesystems.disks.gdrive.folder', $settings['gdrive_folder'] ?? 'LaravelBackups');
}
// 2. Cleanup Policy
$retention = (int) ($settings['backup_db_retention'] ?? 7);
Config::set('backup.cleanup.default_strategy.keep_all_backups_for_days', $retention);
// 3. Encryption
if (! empty($settings['backup_db_encrypt']) && ! empty($settings['backup_db_encrypt_key'])) {
Config::set('backup.backup.password', $settings['backup_db_encrypt_key']);
Config::set('backup.backup.encryption', 'aes256');
} else {
Config::set('backup.backup.password', null);
Config::set('backup.backup.encryption', 'none');
}
// 4. Notifications
$notifyOn = $settings['backup_db_notify_on'] ?? 'failed';
$notifyTo = $settings['backup_db_notify_to'] ?? '';
$channels = [];
if (! empty($notifyTo)) {
if (filter_var($notifyTo, FILTER_VALIDATE_EMAIL)) {
$channels[] = 'mail';
Config::set('backup.notifications.mail.to', $notifyTo);
} elseif (str_starts_with($notifyTo, 'http')) {
$channels[] = 'webhook';
Config::set('backup.notifications.notifications.webhook.url', $notifyTo);
}
}
if ($skipNotifications || $notifyOn === 'none') {
// Use empty channel arrays per-class (Spatie requires all keys to exist)
$allClasses = [
BackupHasFailedNotification::class,
UnhealthyBackupWasFoundNotification::class,
CleanupHasFailedNotification::class,
BackupWasSuccessfulNotification::class,
HealthyBackupWasFoundNotification::class,
CleanupWasSuccessfulNotification::class,
];
Config::set('backup.notifications.notifications', array_fill_keys($allClasses, []));
} else {
$map = [
'success' => [BackupWasSuccessfulNotification::class],
'failed' => [BackupHasFailedNotification::class],
'both' => [
BackupWasSuccessfulNotification::class,
BackupHasFailedNotification::class,
],
];
$classes = $map[$notifyOn] ?? $map['failed'];
$newNotifs = [];
foreach ($classes as $class) {
$newNotifs[$class] = $channels;
}
Config::set('backup.notifications.notifications', $newNotifs);
}
// 5. Exclude Tables (Injected via database config)
$dbDriver = config('database.default');
if (! empty($settings['backup_db_exclude'])) {
$excluded = array_map('trim', explode(',', $settings['backup_db_exclude']));
Config::set("database.connections.{$dbDriver}.dump.exclude_tables", $excluded);
}
// 6. DB-Specific Options (mysqldump / pg_dump)
if ($dbDriver === 'mysql') {
$extraOptions = ['--hex-blob'];
Config::set('database.connections.mysql.dump.add_extra_option', implode(' ', $extraOptions));
// Windows/Laragon mysqldump path fix
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
$candidates = ['C:/laragon/bin/mysql/mysql-8.0.30-winx64/bin'];
$globMatches = glob('C:/laragon/bin/mysql/*/bin', GLOB_ONLYDIR);
if ($globMatches) {
$candidates = array_merge($candidates, $globMatches);
}
foreach ($candidates as $candidate) {
if (is_dir($candidate) && file_exists($candidate.'/mysqldump.exe')) {
Config::set('database.connections.mysql.dump.dump_binary_path', $candidate);
break;
}
}
}
} elseif ($dbDriver === 'pgsql') {
// Postgres specific dump options
// Spatie pg_dump usually works well with defaults, but we can add path if needed
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
$globMatches = glob('C:/laragon/bin/postgresql/*/bin', GLOB_ONLYDIR);
if ($globMatches) {
Config::set('database.connections.pgsql.dump.dump_binary_path', end($globMatches));
}
}
}
}
public function getBackupList(bool $forceConfig = true, bool $forceRefresh = false)
{
if ($forceConfig) {
$this->applyDynamicConfig(true); // Don't need notifications just to list files
}
$cacheKey = 'system.backup_list';
// Helper function for the scan logic
$scan = function () {
$config = SpatieConfig::fromArray(config('backup'));
$backupDestinations = BackupDestinationFactory::createFromArray($config);
$backups = [];
foreach ($backupDestinations as $destination) {
foreach ($destination->backups() as $backup) {
$backups[] = [
'name' => $backup->path(),
'size' => $this->formatBytes($backup->sizeInBytes()),
'storage' => $destination->diskName(),
'date' => $backup->date()->format('Y-m-d H:i:s'),
'timestamp' => $backup->date()->timestamp,
'status' => 'Success',
];
}
}
// Sort by date descending
usort($backups, fn ($a, $b) => $b['timestamp'] <=> $a['timestamp']);
return $backups;
};
if ($forceRefresh) {
$data = $scan();
Cache::put($cacheKey, $data, 30);
return $data;
}
return Cache::remember($cacheKey, 30, $scan);
}
/**
* Get storage health statistics.
*/
public function getStorageStats(?string $driver = null)
{
$this->applyDynamicConfig(true, $driver);
$driver = $driver ?? config('backup.backup.destination.disks.0', 'local');
$total = 0;
$free = 0;
$used = 0;
$label = 'Local Disk';
try {
if ($driver === 'local') {
$total = @disk_total_space(storage_path('app')) ?: 0;
$free = @disk_free_space(storage_path('app')) ?: 0;
$used = $total - $free;
} else {
// For cloud, we might not get total/free easily depending on API
// We'll return just the used size and assume a default total for UI progress (e.g., 15GB for GDrive free)
$backups = $this->getBackupList(forceRefresh: false);
foreach ($backups as $b) {
if ($b['storage'] === $driver) {
$used += $this->parseBytes($b['size']);
}
}
$label = strtoupper($driver);
// Estimate total for progress bar visibility
$total = $driver === 'gdrive' ? (15 * 1024 * 1024 * 1024) : 0;
$free = max(0, $total - $used);
}
} catch (Exception $e) {
Log::error('Failed to get storage stats: '.$e->getMessage());
}
return [
'label' => $label,
'driver' => $driver,
'total' => $this->formatBytes($total),
'free' => $this->formatBytes($free),
'used' => $this->formatBytes($used),
'percentage' => $total > 0 ? round(($used / $total) * 100, 1) : 0,
'health' => ($total > 0 && ($free / $total) < 0.1) ? 'danger' : 'success',
'requirements' => $this->checkRequirements(),
];
}
/**
* Check if system requirements (binaries) for current driver are met.
*/
public function checkRequirements()
{
$dbDriver = config('database.default');
$binary = $dbDriver === 'pgsql' ? 'pg_dump' : ($dbDriver === 'mysql' ? 'mysqldump' : null);
if (! $binary) {
return ['status' => true];
}
$process = new Process(['which', $binary]);
$process->run();
$missing = ! $process->isSuccessful();
return [
'status' => ! $missing,
'binary' => $binary,
'message' => $missing ? __(':bin not found on server. Please install it to enable backups.', ['bin' => $binary]) : null,
];
}
/**
* Test connection to a specific storage disk.
*/
public function testConnection()
{
$this->applyDynamicConfig(true);
$disk = config('backup.backup.destination.disks.0', 'local');
try {
$backupName = config('backup.backup.name', 'biiproject');
// Pre-create directory for Google Drive to avoid reachability check failure
if ($disk === 'gdrive' && ! Storage::disk($disk)->exists($backupName)) {
Storage::disk($disk)->makeDirectory($backupName);
}
Storage::disk($disk)->files($backupName);
return [
'success' => true,
'message' => __('Successfully connected to :disk storage.', ['disk' => strtoupper($disk)]),
];
} catch (Exception $e) {
return [
'success' => false,
'message' => __('Connection failed: ').$e->getMessage(),
];
}
}
private function parseBytes(string $value): float
{
return MonitoringFormatter::parseBytes($value);
}
/**
* Create a new backup.
*
* Uses a child process instead of Artisan::call() to avoid inheriting the
* broken TCP/IP socket context from php artisan serve on Windows.
*/
public function createBackup()
{
$phpBinary = PHP_BINARY;
$artisan = base_path('artisan');
// Ensure dynamic config is applied in the current process
$this->applyDynamicConfig(true);
$disk = config('backup.backup.destination.disks.0', 'local');
$backupName = config('backup.backup.name', 'biiproject');
// Pre-create directory for Google Drive to avoid reachability check failure in child process
if ($disk === 'gdrive') {
try {
if (! Storage::disk($disk)->exists($backupName)) {
Storage::disk($disk)->makeDirectory($backupName);
}
} catch (Exception $e) {
Log::warning('Failed to pre-create GDrive backup directory: '.$e->getMessage());
}
}
$dbDriver = config('database.default');
$binaryPath = config("database.connections.{$dbDriver}.dump.dump_binary_path");
$env = getenv();
// Prepend binary path to PATH for child process
if ($binaryPath) {
$separator = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN' ? ';' : ':';
$env['PATH'] = $binaryPath.$separator.($env['PATH'] ?? '');
}
// Add some basic required env vars if missing
$env['APP_ENV'] = app()->environment();
$env['APP_KEY'] = config('app.key');
$command = array_filter([
$phpBinary,
$artisan,
'backup:run',
'--only-db',
'--disable-notifications',
]);
$process = new Process($command, base_path(), $env);
$process->setTimeout(300); // 5 minutes max
Log::info('Backup: starting subprocess — '.implode(' ', $command));
$process->run();
$stdout = $process->getOutput();
$stderr = $process->getErrorOutput();
Log::info('Backup stdout: '.trim($stdout));
if ($stderr) {
Log::warning('Backup stderr: '.trim($stderr));
}
if (! $process->isSuccessful()) {
$detail = trim($stderr ?: $stdout ?: 'No output captured.');
Notification::send(
User::permission('view notification center')->get(),
new SystemManagementNotification('Backup Failed', "Database backup failed: {$detail}", 'warning', 'Developer')
);
throw new Exception(
"Backup failed (exit {$process->getExitCode()}): {$detail}"
);
}
Notification::send(
User::permission('view notification center')->get(),
new SystemManagementNotification('Backup Successful', 'Automated database backup completed successfully.', 'info', 'Developer')
);
// Bust the cache AFTER the file is confirmed written to disk
Cache::forget('system.backup_list');
return $stdout;
}
/**
* Restore from a backup file.
*/
public function restoreBackup($disk, $path)
{
$this->applyDynamicConfig();
if (! Storage::disk($disk)->exists($path)) {
throw new Exception("Backup file not found on disk: {$disk}");
}
// 1. Enable Maintenance Mode to prevent data corruption
Artisan::call('down', [
'--refresh' => 15,
'--secret' => 'restore-mode',
'--render' => 'errors::503',
]);
try {
// 2. Download/Copy to temp via Stream (Safer for large files)
$tempPath = storage_path('app/temp_restore.zip');
$diskStream = Storage::disk($disk)->readStream($path);
$localStream = fopen($tempPath, 'w+');
if (! $diskStream || ! $localStream) {
throw new Exception('Failed to open streams for restoration.');
}
stream_copy_to_stream($diskStream, $localStream);
fclose($localStream);
if (is_resource($diskStream)) {
fclose($diskStream);
}
// 3. Extract SQL from Zip (with Zip Slip protection)
$zip = new \ZipArchive;
$sqlFile = null;
$extractBase = realpath(storage_path('app'));
if ($zip->open($tempPath) === true) {
for ($i = 0; $i < $zip->numFiles; $i++) {
$filename = $zip->getNameIndex($i);
if (! str_ends_with($filename, '.sql')) {
continue;
}
// Prevent path traversal
$resolvedPath = $extractBase.DIRECTORY_SEPARATOR.ltrim($filename, '/\\');
$realResolved = realpath(dirname($resolvedPath));
if ($realResolved === false || ! str_starts_with($realResolved, $extractBase)) {
$zip->close();
throw new Exception("Invalid path in backup archive: {$filename}");
}
$zip->extractTo($extractBase, $filename);
$sqlFile = $extractBase.DIRECTORY_SEPARATOR.$filename;
break;
}
$zip->close();
}
if (! $sqlFile || ! file_exists($sqlFile)) {
throw new Exception('Could not find SQL file in the backup archive.');
}
// 4. Execute Restore using array-based Process with Streamed Input
$dbDriver = config('database.default');
$dbConfig = config("database.connections.{$dbDriver}");
$binPath = config("database.connections.{$dbDriver}.dump.dump_binary_path", '');
$env = array_merge(getenv(), [
'SystemRoot' => getenv('SystemRoot') ?: 'C:\WINDOWS',
'SystemDrive' => getenv('SystemDrive') ?: 'C:',
'windir' => getenv('windir') ?: 'C:\WINDOWS',
]);
if ($dbDriver === 'mysql') {
$command = [
$binPath ? rtrim($binPath, '/\\').DIRECTORY_SEPARATOR.'mysql' : 'mysql',
'--host='.($dbConfig['host'] ?? '127.0.0.1'),
'--port='.($dbConfig['port'] ?? '3306'),
'--user='.($dbConfig['username'] ?? ''),
'--password='.($dbConfig['password'] ?? ''),
$dbConfig['database'] ?? '',
];
} elseif ($dbDriver === 'pgsql') {
$command = [
$binPath ? rtrim($binPath, '/\\').DIRECTORY_SEPARATOR.'psql' : 'psql',
'--host='.($dbConfig['host'] ?? '127.0.0.1'),
'--port='.($dbConfig['port'] ?? '5432'),
'--username='.($dbConfig['username'] ?? 'postgres'),
'--dbname='.($dbConfig['database'] ?? ''),
];
$env['PGPASSWORD'] = $dbConfig['password'] ?? '';
} else {
throw new Exception('Restore currently only supports mysql or pgsql drivers.');
}
// High-performance streamed restore: open SQL file and pass the handle to Process
$sqlHandle = fopen($sqlFile, 'r');
$process = new Process($command, base_path(), $env, $sqlHandle);
$process->setTimeout(1200); // 20 minutes for very large DBs
$process->run();
if (is_resource($sqlHandle)) {
fclose($sqlHandle);
}
// Cleanup temp files
@unlink($tempPath);
@unlink($sqlFile);
if (! $process->isSuccessful()) {
throw new Exception('Restore execution failed: '.($process->getErrorOutput() ?: $process->getOutput() ?: 'Check logs.'));
}
// 5. Clear Caches & Optimization
Artisan::call('optimize:clear');
return true;
} catch (Exception $e) {
throw $e;
} finally {
// 6. Ensure system comes back UP even if restore failed
Artisan::call('up');
}
}
protected function formatBytes(int|float $bytes, int $precision = 2): string
{
return MonitoringFormatter::bytes($bytes, $precision);
}
}
+121
View File
@@ -0,0 +1,121 @@
<?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
* ============================================================
*/
namespace App\Services\System;
use App\Models\User;
use App\Services\SystemConfig\SettingDefinitions;
class GlobalSearchService
{
/**
* Search for users, pages, and settings.
*/
public function search(string $query): array
{
$results = [];
$query = strtolower(trim($query));
if (empty($query)) {
return $results;
}
// 1. Search Users
$users = User::where('name', 'ILIKE', "%{$query}%")
->orWhere('email', 'ILIKE', "%{$query}%")
->orWhere('username', 'ILIKE', "%{$query}%")
->take(5)
->get()
->map(function ($user) {
return [
'title' => $user->name,
'subtitle' => 'User: '.$user->email,
'url' => route('users').'?search='.urlencode($user->email),
'icon' => 'bi-person',
'category' => 'Users',
];
});
$results = array_merge($results, $users->toArray());
// 2. Search Pages (Menus)
$pages = [
['title' => 'Dashboard', 'url' => route('dashboard'), 'icon' => 'bi-speedometer2'],
['title' => 'User Directory', 'url' => route('users'), 'icon' => 'bi-people'],
['title' => 'Access Rights (Roles)', 'url' => route('roles'), 'icon' => 'bi-shield-lock'],
['title' => 'Permissions', 'url' => route('permissions'), 'icon' => 'bi-key'],
['title' => 'Action Logs', 'url' => route('action-logs'), 'icon' => 'bi-journal-text'],
['title' => 'Notification Center', 'url' => route('notification-center.index'), 'icon' => 'bi-bell'],
['title' => 'Health & Logs', 'url' => route('system-monitoring'), 'icon' => 'bi-activity'],
['title' => 'Session Manager', 'url' => route('session-manager'), 'icon' => 'bi-person-badge'],
['title' => 'Global Settings', 'url' => route('system-config'), 'icon' => 'bi-gear'],
['title' => 'Mobile Settings', 'url' => route('mobile-settings.index'), 'icon' => 'bi-phone'],
['title' => 'Backup & Storage', 'url' => route('backup-restore.index'), 'icon' => 'bi-cloud-download'],
['title' => 'Maintenance Mode', 'url' => route('maintenance-mode'), 'icon' => 'bi-wrench'],
['title' => 'AI Self-Healing', 'url' => route('ai-self-healing.index'), 'icon' => 'bi-heart-pulse'],
['title' => 'My Profile', 'url' => route('profile.edit'), 'icon' => 'bi-person-circle'],
];
foreach ($pages as $page) {
if (str_contains(strtolower($page['title']), $query)) {
$page['category'] = 'Pages';
$page['subtitle'] = 'Navigation';
$results[] = $page;
}
}
// 3. Search Settings (from SettingDefinitions)
$settingsResults = [];
foreach (SettingDefinitions::ALL as $key => $meta) {
$title = str_replace('_', ' ', $key);
$description = $meta['description'];
if (str_contains(strtolower($title), $query) || str_contains(strtolower($description), $query)) {
$settingsResults[] = [
'title' => ucwords($title),
'subtitle' => 'Setting: '.$description,
'url' => route('system-config').'?anchor='.$meta['group'],
'icon' => 'bi-sliders',
'category' => 'Settings',
];
}
if (count($settingsResults) >= 10) {
break;
}
}
$results = array_merge($results, $settingsResults);
// 4. AI Assistance Trigger
if ((str_ends_with($query, '?') || strlen($query) > 10) && auth()->user()->can('use ai assistant')) {
$results[] = [
'title' => 'Ask AI Assistant',
'subtitle' => 'Query: "'.$query.'"',
'url' => '#ask-ai',
'icon' => 'bi-robot',
'category' => 'AI Help',
'is_ai' => true,
'query' => $query,
];
}
return $results;
}
}
@@ -0,0 +1,203 @@
<?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\System;
use App\Events\SystemNotification;
use App\Models\User;
use App\Notifications\SystemManagementNotification;
use App\Services\SystemConfig\SystemConfigService;
use Exception;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
class MaintenanceManagementService
{
public function __construct(
protected SystemConfigService $configService
) {}
/**
* Sync the physical maintenance state with the database configuration.
*/
public function syncState()
{
try {
$settings = $this->configService->all();
$enabled = filter_var($settings['maintenance_mode_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
$isCurrentlyDown = $this->isDown();
if ($enabled) {
$this->activateMaintenance($settings);
// Only notify if it was NOT already down
if (! $isCurrentlyDown) {
Notification::send(
User::permission('view notification center')->get(),
new SystemManagementNotification('Maintenance Active', 'System is entering Maintenance Mode.', 'warning')
);
}
} else {
$this->deactivateMaintenance();
// Only notify if it WAS previously down
if ($isCurrentlyDown) {
Notification::send(
User::permission('view notification center')->get(),
new SystemManagementNotification('System Online', 'System is now LIVE and online.', 'success')
);
}
}
return true;
} catch (Exception $e) {
Log::error('Maintenance Mode Sync Error: '.$e->getMessage());
return false;
}
}
/**
* Activate Laravel's native maintenance mode with dynamic parameters.
*/
protected function activateMaintenance(array $settings)
{
$options = [];
// 1. Secret Bypass URL
if (! empty($settings['maintenance_mode_secret'])) {
$options['--secret'] = $settings['maintenance_mode_secret'];
}
// 2. Refresh (Browser refresh interval)
$options['--refresh'] = 60; // Default 60 seconds
// 3. Retry After Header
if (! empty($settings['maintenance_mode_retry'])) {
$options['--retry'] = (int) $settings['maintenance_mode_retry'];
}
// 4. Allowed IPs
if (! empty($settings['maintenance_mode_allowed_ips'])) {
// Convert textarea lines/commas to array
$ips = preg_split('/[\s,]+/', $settings['maintenance_mode_allowed_ips'], -1, PREG_SPLIT_NO_EMPTY);
if (! empty($ips)) {
$options['--allow'] = $ips;
}
}
// 5. Status Code (Always 503 for maintenance)
$options['--status'] = 503;
Log::info('System: Activating Maintenance Mode', $options);
// Note: Laravel 11+ down command handles these options
Artisan::call('down', $options);
}
/**
* Deactivate maintenance mode.
*/
protected function deactivateMaintenance()
{
if (app()->isDownForMaintenance()) {
Log::info('System: Deactivating Maintenance Mode');
Artisan::call('up');
}
}
/**
* Check if the system is currently in maintenance mode.
*/
public function isDown(): bool
{
return app()->isDownForMaintenance();
}
/**
* Automatically release maintenance mode if the end time has passed.
*/
public function autoCheckAndRelease(): void
{
$settings = $this->configService->all();
$enabled = filter_var($settings['maintenance_mode_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
$endAt = $settings['maintenance_mode_end_at'] ?? null;
if (! $enabled || empty($endAt)) {
return;
}
try {
$endTime = new \DateTime($endAt);
$now = new \DateTime;
if ($now >= $endTime) {
Log::info('System: Maintenance window expired. Automatically deactivating maintenance mode.', [
'expired_at' => $endAt,
'current_time' => $now->format('Y-m-d H:i:s'),
]);
// Update configuration in DB
$this->configService->update([
'maintenance_mode_enabled' => false,
'maintenance_mode_end_at' => null, // Optional: Clear the end time
]);
// Physical release (artisan up)
$this->syncState();
}
} catch (Exception $e) {
Log::error('System: Automatic Maintenance Release failed: '.$e->getMessage());
}
}
/**
* Broadcast a real-time warning to all active users via WebSockets.
*/
public function broadcastWarning(int $minutes)
{
$message = __('SYSTEM ALERT: The system will enter maintenance mode in :min minutes. Please save your work immediately.', ['min' => $minutes]);
// 1. Broadcast event for real-time UI notification (WebSockets)
event(new SystemNotification(
message: $message,
type: 'warning',
title: __('Maintenance Warning')
));
// 2. Persistent notification in the database
Notification::send(
User::all(), // Notify everyone
new SystemManagementNotification('Scheduled Maintenance', $message, 'warning', 'Developer')
);
Log::info("System: Broadcasted maintenance warning (Starting in {$minutes}m).");
return true;
}
}
@@ -0,0 +1,204 @@
<?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\System;
use App\Events\SystemNotification;
use App\Models\User;
use App\Notifications\SystemManagementNotification;
use App\Services\SystemConfig\SystemConfigService;
use Exception;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
class MaintenanceManagementService
{
public function __construct(
protected SystemConfigService $configService
) {}
/**
* Sync the physical maintenance state with the database configuration.
*/
public function syncState()
{
try {
$settings = $this->configService->all();
$enabled = filter_var($settings['maintenance_mode_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
$isCurrentlyDown = $this->isDown();
if ($enabled) {
$this->activateMaintenance($settings);
// Only notify if it was NOT already down
if (! $isCurrentlyDown) {
Notification::send(
User::permission('view notification center')->get(),
new SystemManagementNotification('Maintenance Active', 'System is entering Maintenance Mode.', 'warning')
);
}
} else {
$this->deactivateMaintenance();
// Only notify if it WAS previously down
if ($isCurrentlyDown) {
Notification::send(
User::permission('view notification center')->get(),
new SystemManagementNotification('System Online', 'System is now LIVE and online.', 'success')
);
}
}
return true;
} catch (Exception $e) {
Log::error('Maintenance Mode Sync Error: '.$e->getMessage());
return false;
}
}
/**
* Activate Laravel's native maintenance mode with dynamic parameters.
*/
protected function activateMaintenance(array $settings)
{
$options = [];
// 1. Secret Bypass URL
if (! empty($settings['maintenance_mode_secret'])) {
$options['--secret'] = $settings['maintenance_mode_secret'];
}
// 2. Refresh (Browser refresh interval)
$options['--refresh'] = 60; // Default 60 seconds
// 3. Retry After Header
if (! empty($settings['maintenance_mode_retry'])) {
$options['--retry'] = (int) $settings['maintenance_mode_retry'];
}
// 4. Allowed IPs
if (! empty($settings['maintenance_mode_allowed_ips'])) {
// Convert textarea lines/commas to array
$ips = preg_split('/[\s,]+/', $settings['maintenance_mode_allowed_ips'], -1, PREG_SPLIT_NO_EMPTY);
if (! empty($ips)) {
$options['--allow'] = $ips;
}
}
// 5. Status Code (Always 503 for maintenance)
$options['--status'] = 503;
Log::info('System: Activating Maintenance Mode', $options);
// Note: Laravel 11+ down command handles these options
Artisan::call('down', $options);
}
/**
* Deactivate maintenance mode.
*/
protected function deactivateMaintenance()
{
if (app()->isDownForMaintenance()) {
Log::info('System: Deactivating Maintenance Mode');
Artisan::call('up');
}
}
/**
* Check if the system is currently in maintenance mode.
*/
public function isDown(): bool
{
return app()->isDownForMaintenance();
}
/**
* Automatically release maintenance mode if the end time has passed.
*/
public function autoCheckAndRelease(): void
{
$settings = $this->configService->all();
$enabled = filter_var($settings['maintenance_mode_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
$endAt = $settings['maintenance_mode_end_at'] ?? null;
if (! $enabled || empty($endAt)) {
return;
}
try {
$endTime = new \DateTime($endAt);
$now = new \DateTime;
if ($now >= $endTime) {
Log::info('System: Maintenance window expired. Automatically deactivating maintenance mode.', [
'expired_at' => $endAt,
'current_time' => $now->format('Y-m-d H:i:s'),
]);
// Update configuration in DB
$this->configService->update([
'maintenance_mode_enabled' => false,
'maintenance_mode_end_at' => null, // Optional: Clear the end time
]);
// Physical release (artisan up)
$this->syncState();
}
} catch (Exception $e) {
Log::error('System: Automatic Maintenance Release failed: '.$e->getMessage());
}
}
/**
* Broadcast a real-time warning to all active users via WebSockets.
*/
public function broadcastWarning(int $minutes)
{
$message = __('SYSTEM ALERT: The system will enter maintenance mode in :min minutes. Please save your work immediately.', ['min' => $minutes]);
// 1. Broadcast event for real-time UI notification (WebSockets)
event(new SystemNotification(
title: __('Maintenance Warning'),
message: $message,
type: 'warning',
user_id: null // Broadcast to all
));
// 2. Persistent notification in the database
Notification::send(
User::all(), // Notify everyone
new SystemManagementNotification('Scheduled Maintenance', $message, 'warning', 'Developer')
);
Log::info("System: Broadcasted maintenance warning (Starting in {$minutes}m).");
return true;
}
}