| <?php |
|
|
| namespace App\Http\Controllers; |
|
|
| use App\Models\SocialAutomationSetting; |
| use App\Models\SocialPost; |
| use App\Services\SocialContentGeneratorService; |
| use App\Services\SocialAutomationHealthService; |
| use App\Services\SocialPublisherService; |
| use Illuminate\Http\RedirectResponse; |
| use Illuminate\Http\Request; |
| use Illuminate\Support\Carbon; |
| use Illuminate\View\View; |
|
|
| class SocialAutomationController extends Controller |
| { |
| public function index(Request $request): View |
| { |
| $user = $request->user(); |
| $setting = $this->settingFor($user->id); |
|
|
| $credentials = $user->socialPlatformCredentials() |
| ->get() |
| ->keyBy('platform'); |
|
|
| $posts = $user->socialPosts() |
| ->latest() |
| ->paginate(12) |
| ->withQueryString(); |
|
|
| $stats = [ |
| 'scheduled' => $user->socialPosts()->where('status', 'scheduled')->count(), |
| 'posted' => $user->socialPosts()->where('status', 'posted')->count(), |
| 'failed' => $user->socialPosts()->where('status', 'failed')->count(), |
| 'need_approval' => $user->socialPosts()->where('requires_approval', true)->where('status', 'draft')->count(), |
| ]; |
|
|
| return view('social.index', [ |
| 'credentials' => $credentials, |
| 'posts' => $posts, |
| 'stats' => $stats, |
| 'platforms' => $this->platforms(), |
| 'setting' => $setting, |
| ]); |
| } |
|
|
| public function credentialsIndex(Request $request, SocialAutomationHealthService $health): View |
| { |
| $user = $request->user(); |
| $platforms = $this->platforms(); |
|
|
| return view('social.credentials', [ |
| 'credentials' => $user->socialPlatformCredentials() |
| ->get() |
| ->keyBy('platform'), |
| 'cronStatus' => $health->cronStatus(), |
| 'platformProgress' => $health->platformProgress($user, $platforms), |
| 'platforms' => $platforms, |
| ]); |
| } |
|
|
| public function generate(Request $request, SocialContentGeneratorService $generator): RedirectResponse |
| { |
| $data = $request->validate([ |
| 'topic' => ['required', 'string', 'max:350'], |
| 'tone' => ['nullable', 'string', 'max:100'], |
| 'platform_hint' => ['nullable', 'string', 'max:100'], |
| ]); |
|
|
| try { |
| $result = $generator->generate( |
| $data['topic'], |
| $data['platform_hint'] ?? 'facebook, instagram, tiktok, x', |
| $data['tone'] ?? 'friendly-professional' |
| ); |
| } catch (\Throwable $e) { |
| return redirect()->route('social.index') |
| ->with('social_error', $e->getMessage()) |
| ->withInput(); |
| } |
|
|
| return redirect()->route('social.index')->with('social_generated', $result); |
| } |
|
|
| public function store(Request $request): RedirectResponse |
| { |
| $data = $request->validate([ |
| 'platforms' => ['required', 'array', 'min:1'], |
| 'platforms.*' => ['required', 'in:facebook,instagram,tiktok,x'], |
| 'title' => ['nullable', 'string', 'max:150'], |
| 'caption' => ['required', 'string', 'max:2000'], |
| 'hashtags' => ['nullable', 'string', 'max:500'], |
| 'image_url' => ['nullable', 'url', 'max:2048'], |
| 'video_url' => ['nullable', 'url', 'max:2048'], |
| 'scheduled_at' => ['required', 'date'], |
| 'status' => ['required', 'in:draft,scheduled'], |
| ]); |
|
|
| $hashtags = collect(explode(',', (string) ($data['hashtags'] ?? ''))) |
| ->map(fn ($item) => trim($item)) |
| ->filter() |
| ->map(fn ($item) => ltrim($item, '#')) |
| ->values() |
| ->all(); |
|
|
| $setting = $this->settingFor($request->user()->id); |
| $scheduledAt = Carbon::parse($data['scheduled_at']); |
| |
| if (in_array('instagram', $data['platforms'], true) && blank($data['image_url'] ?? null)) { |
| return redirect()->route('social.index') |
| ->with('social_error', 'Instagram membutuhkan image_url publik sebelum post bisa dibuat.') |
| ->withInput(); |
| } |
|
|
| if (in_array('tiktok', $data['platforms'], true) && blank($data['video_url'] ?? null)) { |
| return redirect()->route('social.index') |
| ->with('social_error', 'TikTok membutuhkan video_url publik sebelum post bisa dibuat.') |
| ->withInput(); |
| } |
|
|
| foreach ($data['platforms'] as $platform) { |
| $requiresApproval = ($data['status'] === 'scheduled') && ($setting->approval_mode === 'manual'); |
| $status = $requiresApproval ? 'draft' : $data['status']; |
|
|
| $request->user()->socialPosts()->create([ |
| 'platform' => $platform, |
| 'status' => $status, |
| 'requires_approval' => $requiresApproval, |
| 'title' => $data['title'] ?? null, |
| 'caption' => $data['caption'], |
| 'hashtags' => $hashtags, |
| 'image_url' => $data['image_url'] ?? null, |
| 'video_url' => $data['video_url'] ?? null, |
| 'scheduled_at' => $scheduledAt, |
| 'approved_at' => $requiresApproval ? null : now(), |
| ]); |
| } |
|
|
| return redirect()->route('social.index')->with('social_success', 'Post berhasil ditambahkan ke antrian.'); |
| } |
|
|
| public function saveCredential(Request $request): RedirectResponse |
| { |
| $data = $request->validate([ |
| 'platform' => ['required', 'in:facebook,instagram,tiktok,x'], |
| 'account_label' => ['nullable', 'string', 'max:120'], |
| 'account_id' => ['nullable', 'string', 'max:190'], |
| 'access_token' => ['nullable', 'string', 'max:4000'], |
| 'meta_client_id' => ['nullable', 'string', 'max:200'], |
| 'meta_client_secret' => ['nullable', 'string', 'max:200'], |
| ]); |
|
|
| $existing = $request->user()->socialPlatformCredentials() |
| ->where('platform', $data['platform']) |
| ->first(); |
|
|
| $existingMeta = $existing?->meta ?? []; |
| $meta = [ |
| 'client_id' => filled($data['meta_client_id'] ?? null) |
| ? $data['meta_client_id'] |
| : data_get($existingMeta, 'client_id'), |
| 'client_secret' => filled($data['meta_client_secret'] ?? null) |
| ? $data['meta_client_secret'] |
| : data_get($existingMeta, 'client_secret'), |
| ]; |
|
|
| $request->user()->socialPlatformCredentials()->updateOrCreate( |
| ['platform' => $data['platform']], |
| [ |
| 'account_label' => $data['account_label'] ?? null, |
| 'account_id' => $data['account_id'] ?? null, |
| 'access_token' => filled($data['access_token'] ?? null) |
| ? $data['access_token'] |
| : ($existing?->access_token), |
| 'meta' => $meta, |
| ] |
| ); |
|
|
| return redirect() |
| ->to(route('social.credentials.index').'#platform-'.$data['platform']) |
| ->with('social_success', 'Credential platform tersimpan.'); |
| } |
|
|
| public function saveSettings(Request $request): RedirectResponse |
| { |
| $data = $request->validate([ |
| 'approval_mode' => ['required', 'in:manual,auto'], |
| 'daily_limit' => ['required', 'integer', 'min:1', 'max:500'], |
| 'facebook_daily_limit' => ['required', 'integer', 'min:0', 'max:500'], |
| 'instagram_daily_limit' => ['required', 'integer', 'min:0', 'max:500'], |
| 'tiktok_daily_limit' => ['required', 'integer', 'min:0', 'max:500'], |
| 'x_daily_limit' => ['required', 'integer', 'min:0', 'max:500'], |
| 'max_retries' => ['required', 'integer', 'min:0', 'max:10'], |
| 'retry_delay_minutes' => ['required', 'integer', 'min:1', 'max:1440'], |
| ]); |
|
|
| $platformDailyLimits = [ |
| 'facebook' => (int) $data['facebook_daily_limit'], |
| 'instagram' => (int) $data['instagram_daily_limit'], |
| 'tiktok' => (int) $data['tiktok_daily_limit'], |
| 'x' => (int) $data['x_daily_limit'], |
| ]; |
|
|
| SocialAutomationSetting::query()->updateOrCreate( |
| ['user_id' => $request->user()->id], |
| [ |
| 'approval_mode' => $data['approval_mode'], |
| 'daily_limit' => (int) $data['daily_limit'], |
| 'platform_daily_limits' => $platformDailyLimits, |
| 'max_retries' => (int) $data['max_retries'], |
| 'retry_delay_minutes' => (int) $data['retry_delay_minutes'], |
| ] |
| ); |
|
|
| return redirect()->route('social.index')->with('social_success', 'Pengaturan automation berhasil disimpan.'); |
| } |
|
|
| public function approvePost(Request $request, SocialPost $post): RedirectResponse |
| { |
| abort_unless($post->user_id === $request->user()->id, 403); |
| |
| if (! $post->requires_approval) { |
| return redirect()->route('social.index')->with('social_error', 'Post ini tidak membutuhkan approval.'); |
| } |
|
|
| $post->update([ |
| 'requires_approval' => false, |
| 'status' => 'scheduled', |
| 'approved_at' => now(), |
| 'error_message' => null, |
| ]); |
|
|
| return redirect()->route('social.index')->with('social_success', 'Post sudah di-approve dan masuk antrian scheduled.'); |
| } |
|
|
| public function publishNow(Request $request, SocialPost $post, SocialPublisherService $publisher): RedirectResponse |
| { |
| abort_unless($post->user_id === $request->user()->id, 403); |
| |
| if ($post->status === 'posted') { |
| return redirect()->route('social.index')->with('social_error', 'Post ini sudah pernah dipublish.'); |
| } |
|
|
| if ($post->requires_approval) { |
| return redirect()->route('social.index')->with('social_error', 'Post masih menunggu approval.'); |
| } |
|
|
| [$allowed, $limitMessage] = $this->canPublishNow($request->user()->id, $post->platform); |
| if (! $allowed) { |
| return redirect()->route('social.index')->with('social_error', $limitMessage); |
| } |
|
|
| $credential = $request->user()->socialPlatformCredentials() |
| ->where('platform', $post->platform) |
| ->first(); |
|
|
| if (! $credential) { |
| return redirect()->route('social.index')->with('social_error', 'Credential untuk platform '.$post->platform.' belum diisi.'); |
| } |
|
|
| try { |
| $result = $publisher->publish($post, $credential); |
|
|
| $post->update([ |
| 'status' => 'posted', |
| 'posted_at' => now(), |
| 'approved_at' => $post->approved_at ?? now(), |
| 'external_post_id' => $result['external_post_id'] ?? null, |
| 'error_message' => null, |
| 'meta' => $result['payload'] ?? null, |
| 'next_retry_at' => null, |
| ]); |
|
|
| return redirect()->route('social.index')->with('social_success', 'Post berhasil dipublish ke '.$post->platform.'.'); |
| } catch (\Throwable $e) { |
| $post->update([ |
| 'status' => 'failed', |
| 'attempt_count' => $post->attempt_count + 1, |
| 'error_message' => $e->getMessage(), |
| 'next_retry_at' => null, |
| ]); |
|
|
| return redirect()->route('social.index')->with('social_error', $e->getMessage()); |
| } |
| } |
|
|
| private function settingFor(int $userId): SocialAutomationSetting |
| { |
| return SocialAutomationSetting::query()->firstOrCreate( |
| ['user_id' => $userId], |
| [ |
| 'approval_mode' => 'manual', |
| 'daily_limit' => 20, |
| 'platform_daily_limits' => [ |
| 'facebook' => 8, |
| 'instagram' => 8, |
| 'tiktok' => 4, |
| 'x' => 12, |
| ], |
| 'max_retries' => 2, |
| 'retry_delay_minutes' => 15, |
| ] |
| ); |
| } |
|
|
| private function canPublishNow(int $userId, string $platform): array |
| { |
| $setting = $this->settingFor($userId); |
|
|
| $today = now()->toDateString(); |
| $postedToday = SocialPost::query() |
| ->where('user_id', $userId) |
| ->where('status', 'posted') |
| ->whereDate('posted_at', $today) |
| ->count(); |
|
|
| if ($postedToday >= $setting->daily_limit) { |
| return [false, 'Batas harian total post sudah tercapai.']; |
| } |
|
|
| $platformLimits = $setting->platform_daily_limits ?? []; |
| $platformLimit = (int) ($platformLimits[$platform] ?? 0); |
|
|
| if ($platformLimit > 0) { |
| $postedPerPlatform = SocialPost::query() |
| ->where('user_id', $userId) |
| ->where('platform', $platform) |
| ->where('status', 'posted') |
| ->whereDate('posted_at', $today) |
| ->count(); |
|
|
| if ($postedPerPlatform >= $platformLimit) { |
| return [false, 'Batas harian platform '.$platform.' sudah tercapai.']; |
| } |
| } |
|
|
| return [true, null]; |
| } |
|
|
| private function platforms(): array |
| { |
| return [ |
| 'facebook' => 'Facebook Page', |
| 'instagram' => 'Instagram Business', |
| 'tiktok' => 'TikTok', |
| 'x' => 'X / Twitter', |
| ]; |
| } |
| } |
|
|