teknolis / app /Services /SocialAutomationHealthService.php
Codex
Deploy latest assistant, DeepSeek defaults, and automation updates
74acf19
Raw
History Blame Contribute Delete
12 kB
<?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.';
}
}