feat: add app and database modules
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user