| <?php |
|
|
| namespace App\Services; |
|
|
| use App\Models\User; |
| use Illuminate\Support\Carbon; |
| use Illuminate\Support\Str; |
|
|
| class SocialAutomationHealthService |
| { |
| public function platformProgress(User $user, array $platforms): array |
| { |
| $credentials = $user->socialPlatformCredentials() |
| ->get() |
| ->keyBy('platform'); |
|
|
| $postStats = $user->socialPosts() |
| ->selectRaw('platform, status, COUNT(*) as aggregate') |
| ->groupBy('platform', 'status') |
| ->get() |
| ->groupBy('platform'); |
|
|
| $approvalStats = $user->socialPosts() |
| ->selectRaw('platform, COUNT(*) as aggregate') |
| ->where('requires_approval', true) |
| ->where('status', 'draft') |
| ->groupBy('platform') |
| ->pluck('aggregate', 'platform'); |
|
|
| $lastActivity = $user->socialPosts() |
| ->select(['platform', 'status', 'scheduled_at', 'posted_at', 'updated_at']) |
| ->latest('updated_at') |
| ->get() |
| ->groupBy('platform') |
| ->map(fn ($items) => $items->first()); |
|
|
| return collect($platforms)->mapWithKeys(function (string $label, string $platform) use ($credentials, $postStats, $approvalStats, $lastActivity): array { |
| $credential = $credentials->get($platform); |
| $statusCounts = collect($postStats->get($platform, collect())) |
| ->pluck('aggregate', 'status'); |
|
|
| $counts = [ |
| 'draft' => (int) ($statusCounts->get('draft') ?? 0), |
| 'scheduled' => (int) ($statusCounts->get('scheduled') ?? 0), |
| 'posted' => (int) ($statusCounts->get('posted') ?? 0), |
| 'failed' => (int) ($statusCounts->get('failed') ?? 0), |
| 'approval' => (int) ($approvalStats->get($platform) ?? 0), |
| ]; |
|
|
| $hasToken = filled($credential?->access_token); |
| $hasIdentity = filled($credential?->account_id) || filled($credential?->account_label); |
| $hasLabel = filled($credential?->account_label); |
| $hasQueue = array_sum($counts) > 0; |
| $hasSuccessfulPost = $counts['posted'] > 0; |
|
|
| $steps = [ |
| ['label' => 'Access token connected', 'done' => $hasToken, 'weight' => 45], |
| ['label' => 'Account identity saved', 'done' => $hasIdentity, 'weight' => 20], |
| ['label' => 'Brand label attached', 'done' => $hasLabel, 'weight' => 10], |
| ['label' => 'Queue item prepared', 'done' => $hasQueue, 'weight' => 15], |
| ['label' => 'Successful publish recorded', 'done' => $hasSuccessfulPost, 'weight' => 10], |
| ]; |
|
|
| $completion = (int) collect($steps) |
| ->filter(fn (array $step) => $step['done']) |
| ->sum('weight'); |
|
|
| $last = $lastActivity->get($platform); |
| $lastActivityAt = collect([ |
| $last?->posted_at, |
| $last?->scheduled_at, |
| $last?->updated_at, |
| ])->filter()->first(); |
|
|
| return [ |
| $platform => [ |
| 'platform' => $platform, |
| 'label' => $label, |
| 'completion' => $completion, |
| 'state' => $this->platformState($completion, $counts), |
| 'state_tone' => $this->platformStateTone($completion, $counts), |
| 'credential_ready' => $hasToken, |
| 'counts' => $counts, |
| 'next_action' => $this->nextAction($hasToken, $hasQueue, $hasSuccessfulPost, $counts), |
| 'last_activity_at' => $lastActivityAt instanceof Carbon ? $lastActivityAt : null, |
| 'last_status' => $last?->status, |
| 'steps' => collect($steps) |
| ->map(fn (array $step) => [ |
| 'label' => $step['label'], |
| 'done' => $step['done'], |
| ]) |
| ->all(), |
| ], |
| ]; |
| })->all(); |
| } |
|
|
| public function cronStatus(): array |
| { |
| $expectedCommand = sprintf( |
| '* * * * * cd %s && php artisan schedule:run >> /dev/null 2>&1', |
| base_path() |
| ); |
| $runnerCommand = 'php artisan social:publish-scheduled --limit=30'; |
| $isHuggingFaceSpace = $this->isHuggingFaceSpace(); |
|
|
| $serviceOutput = trim((string) $this->safeShell('systemctl is-active cron 2>/dev/null')); |
| $serviceStatus = in_array($serviceOutput, ['active', 'inactive', 'failed'], true) |
| ? $serviceOutput |
| : 'unknown'; |
|
|
| $scheduleEntry = $this->detectScheduleEntry(); |
| $processOutput = trim((string) $this->safeShell("ps -eo args 2>/dev/null | grep -E 'artisan (schedule:work|social:publish-scheduled|queue:work|queue:listen)' | grep -v grep")); |
| $processRunning = $processOutput !== ''; |
|
|
| [$state, $headline, $detail] = match (true) { |
| $isHuggingFaceSpace && $processRunning => [ |
| 'warning', |
| 'Managed container runner', |
| 'Space ini jalan di container Hugging Face, jadi cron Linux tidak dijamin tersedia. Saat ini ada proses artisan aktif sebagai fallback runner.', |
| ], |
| $isHuggingFaceSpace => [ |
| 'warning', |
| 'Managed container without cron', |
| 'Hugging Face Spaces tidak menyediakan cron Linux yang stabil seperti VPS biasa, jadi scheduled publish butuh trigger eksternal atau worker terpisah.', |
| ], |
| $serviceStatus !== 'active' => [ |
| 'critical', |
| 'Cron service inactive', |
| 'Scheduler Laravel belum akan jalan otomatis karena service cron di server masih mati.', |
| ], |
| $scheduleEntry['found'] => [ |
| 'healthy', |
| 'Cron scheduler connected', |
| 'Cron Linux aktif dan entry Laravel scheduler sudah ditemukan, jadi publish terjadwal bisa dieksekusi otomatis.', |
| ], |
| $processRunning => [ |
| 'warning', |
| 'Manual runner detected', |
| 'Belum ada entry cron Laravel yang jelas, tapi ada proses artisan yang sedang berjalan sebagai fallback manual.', |
| ], |
| default => [ |
| 'warning', |
| 'Cron entry missing', |
| 'Service cron aktif, tapi entry `schedule:run` belum ditemukan sehingga automation berisiko tidak pernah mengeksekusi scheduled publish.', |
| ], |
| }; |
|
|
| return [ |
| 'state' => $state, |
| 'headline' => $headline, |
| 'detail' => $detail, |
| 'service_status' => $serviceStatus, |
| 'schedule_entry_found' => $scheduleEntry['found'], |
| 'schedule_entry_source' => $scheduleEntry['source'], |
| 'runner_detected' => $processRunning, |
| 'runner_command' => $processOutput !== '' ? $processOutput : null, |
| 'expected_cron' => $expectedCommand, |
| 'publish_command' => $runnerCommand, |
| 'managed_runtime' => $isHuggingFaceSpace ? 'huggingface_spaces' : null, |
| 'checked_at' => now(), |
| ]; |
| } |
|
|
| private function detectScheduleEntry(): array |
| { |
| $shellDetected = $this->detectScheduleEntryViaShell(); |
|
|
| if ($shellDetected['found']) { |
| return $shellDetected; |
| } |
|
|
| $candidates = array_filter(array_merge( |
| ['/etc/crontab'], |
| glob('/etc/cron.d/*') ?: [], |
| glob('/var/spool/cron/crontabs/*') ?: [] |
| )); |
|
|
| foreach ($candidates as $path) { |
| if (! $this->isReadableWithinOpenBaseDir($path)) { |
| continue; |
| } |
|
|
| $contents = @file_get_contents($path); |
| if (! is_string($contents) || $contents === '') { |
| continue; |
| } |
|
|
| if (Str::contains($contents, 'schedule:run') || Str::contains($contents, 'schedule:work')) { |
| return [ |
| 'found' => true, |
| 'source' => $path, |
| ]; |
| } |
| } |
|
|
| return [ |
| 'found' => false, |
| 'source' => null, |
| ]; |
| } |
|
|
| private function detectScheduleEntryViaShell(): array |
| { |
| $output = trim((string) $this->safeShell( |
| "grep -R -n -m 1 -E 'schedule:(run|work)' /etc/crontab /etc/cron.d /var/spool/cron /var/spool/cron/crontabs 2>/dev/null" |
| )); |
|
|
| if ($output === '') { |
| return [ |
| 'found' => false, |
| 'source' => null, |
| ]; |
| } |
|
|
| $firstLine = trim(Str::before($output, PHP_EOL)); |
| $source = trim(Str::before($firstLine, ':')); |
|
|
| return [ |
| 'found' => true, |
| 'source' => $source !== '' ? $source : $firstLine, |
| ]; |
| } |
|
|
| private function isReadableWithinOpenBaseDir(string $path): bool |
| { |
| $openBaseDir = trim((string) ini_get('open_basedir')); |
|
|
| if ($openBaseDir === '') { |
| return @is_readable($path); |
| } |
|
|
| $normalizedPath = $this->normalizePath($path); |
| $allowedRoots = collect(explode(PATH_SEPARATOR, $openBaseDir)) |
| ->map(fn (string $root) => $this->normalizePath($root)) |
| ->filter(); |
|
|
| $isAllowed = $allowedRoots->contains(fn (string $root) => Str::startsWith($normalizedPath, $root)); |
|
|
| return $isAllowed && @is_readable($path); |
| } |
|
|
| private function normalizePath(string $path): string |
| { |
| return rtrim(str_replace('\\', '/', trim($path)), '/'); |
| } |
|
|
| private function safeShell(string $command): ?string |
| { |
| if (app()->runningUnitTests() || ! function_exists('shell_exec')) { |
| return null; |
| } |
|
|
| try { |
| $output = @shell_exec($command); |
| } catch (\Throwable) { |
| return null; |
| } |
|
|
| return is_string($output) ? $output : null; |
| } |
|
|
| private function isHuggingFaceSpace(): bool |
| { |
| $appUrl = trim((string) config('app.url', '')); |
| $spaceHost = trim((string) ( |
| $_ENV['SPACE_HOST'] |
| ?? $_SERVER['SPACE_HOST'] |
| ?? getenv('SPACE_HOST') |
| ?? '' |
| )); |
|
|
| return Str::contains($appUrl, '.hf.space') |
| || Str::endsWith($spaceHost, '.hf.space'); |
| } |
|
|
| private function platformState(int $completion, array $counts): string |
| { |
| return match (true) { |
| $completion >= 100 => 'Live', |
| $counts['failed'] > 0 && $completion >= 45 => 'Needs attention', |
| $completion >= 75 => 'Ready', |
| $completion >= 45 => 'Configuring', |
| default => 'Setup needed', |
| }; |
| } |
|
|
| private function platformStateTone(int $completion, array $counts): string |
| { |
| return match (true) { |
| $completion >= 100 => 'healthy', |
| $counts['failed'] > 0 && $completion >= 45 => 'critical', |
| $completion >= 75 => 'ready', |
| $completion >= 45 => 'warning', |
| default => 'pending', |
| }; |
| } |
|
|
| private function nextAction(bool $hasToken, bool $hasQueue, bool $hasSuccessfulPost, array $counts): string |
| { |
| if (! $hasToken) { |
| return 'Simpan access token dulu supaya platform bisa ikut publish otomatis.'; |
| } |
|
|
| if ($counts['failed'] > 0) { |
| return 'Ada post yang gagal. Cek credential, retry, atau media requirement platform ini.'; |
| } |
|
|
| if (! $hasQueue) { |
| return 'Credential sudah siap. Langkah berikutnya tinggal kirim draft pertama ke queue.'; |
| } |
|
|
| if (! $hasSuccessfulPost) { |
| return 'Queue sudah ada. Tinggal tunggu approval atau scheduler untuk publish pertama.'; |
| } |
|
|
| return 'Platform ini sudah punya jejak publish dan siap dipakai untuk campaign berikutnya.'; |
| } |
| } |
|
|