| <?php |
|
|
| namespace App\Http\Controllers; |
|
|
| use App\Models\SocialAutomationSetting; |
| use App\Models\SocialMediaAsset; |
| use App\Models\SocialPost; |
| use App\Models\SocialRecurringAlert; |
| use App\Models\SocialRecurringCampaign; |
| use App\Services\SocialRecurringCampaignService; |
| use App\Services\SocialContentGeneratorService; |
| use App\Services\FreepikMusicService; |
| use App\Services\SocialAutomationHealthService; |
| use App\Services\SocialMediaStudioService; |
| use App\Services\SocialPostMediaPreparationService; |
| use App\Services\SocialPublisherService; |
| use App\Services\VideoMusicComposerService; |
| use Illuminate\Http\JsonResponse; |
| use Illuminate\Http\RedirectResponse; |
| use Illuminate\Http\Request; |
| use Illuminate\Support\Carbon; |
| use Illuminate\Support\Facades\Storage; |
| use Illuminate\Support\Str; |
| use Illuminate\View\View; |
|
|
| class SocialAutomationController extends Controller |
| { |
| public function index(Request $request): View |
| { |
| $user = $request->user(); |
| $setting = $this->settingFor($user->id); |
| $campaignStatusFilter = (string) $request->query('campaign_status', 'all'); |
| if (! in_array($campaignStatusFilter, ['all', 'active', 'paused', 'completed'], true)) { |
| $campaignStatusFilter = 'all'; |
| } |
|
|
| $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(), |
| ]; |
|
|
| $imageGallery = $user->socialMediaAssets() |
| ->where('type', 'image') |
| ->latest('id') |
| ->limit(24) |
| ->get(); |
|
|
| $videoGallery = $user->socialMediaAssets() |
| ->where('type', 'video') |
| ->latest('id') |
| ->limit(18) |
| ->get(); |
|
|
| $recurringCampaigns = $user->socialRecurringCampaigns() |
| ->when($campaignStatusFilter !== 'all', fn ($query) => $query->where('status', $campaignStatusFilter)) |
| ->latest('id') |
| ->limit(12) |
| ->get(); |
|
|
| $recurringHistory = $user->socialPosts() |
| ->when( |
| $recurringCampaigns->isNotEmpty(), |
| fn ($query) => $query->whereIn('recurrence_campaign_id', $recurringCampaigns->pluck('id')->all()), |
| fn ($query) => $query->whereRaw('1 = 0') |
| ) |
| ->latest('scheduled_at') |
| ->limit(120) |
| ->get() |
| ->groupBy('recurrence_campaign_id'); |
|
|
| $recurringAlerts = $user->socialRecurringAlerts() |
| ->whereNull('read_at') |
| ->latest('id') |
| ->limit(8) |
| ->get(); |
|
|
| return view('social.index', [ |
| 'credentials' => $credentials, |
| 'posts' => $posts, |
| 'stats' => $stats, |
| 'platforms' => $this->platforms(), |
| 'setting' => $setting, |
| 'imageGallery' => $imageGallery, |
| 'videoGallery' => $videoGallery, |
| 'recurringCampaigns' => $recurringCampaigns, |
| 'recurringHistory' => $recurringHistory, |
| 'recurringAlerts' => $recurringAlerts, |
| 'campaignStatusFilter' => $campaignStatusFilter, |
| ]); |
| } |
|
|
| 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 generateAndSchedule( |
| Request $request, |
| SocialContentGeneratorService $generator, |
| SocialMediaStudioService $studio, |
| SocialRecurringCampaignService $recurringCampaigns |
| ): RedirectResponse |
| { |
| $data = $request->validate([ |
| 'topic' => ['required', 'string', 'max:350'], |
| 'tone' => ['nullable', 'string', 'max:100'], |
| 'platform_hint' => ['nullable', 'string', 'max:100'], |
| 'platforms' => ['required', 'array', 'min:1'], |
| 'platforms.*' => ['required', 'in:facebook,instagram,tiktok,x'], |
| 'image_url' => ['nullable', 'string', 'max:2048'], |
| 'video_url' => ['nullable', 'string', 'max:2048'], |
| 'auto_generate_image' => ['nullable', 'in:0,1'], |
| 'auto_generate_video' => ['nullable', 'in:0,1'], |
| 'image_prompt' => ['nullable', 'string', 'max:1000'], |
| 'video_prompt' => ['nullable', 'string', 'max:1000'], |
| 'bgm_auto' => ['nullable', 'in:0,1'], |
| 'bgm_prompt' => ['nullable', 'string', 'max:600'], |
| 'bgm_duration' => ['nullable', 'integer', 'min:5', 'max:60'], |
| 'bgm_volume' => ['nullable', 'numeric', 'min:0.05', 'max:1'], |
| 'scheduled_at' => ['required', 'date'], |
| 'status' => ['required', 'in:draft,scheduled'], |
| 'repeat_frequency' => ['nullable', 'in:none,daily,weekly'], |
| 'repeat_until' => ['nullable', 'date', 'after:scheduled_at'], |
| 'repeat_refresh_content' => ['nullable', 'in:0,1'], |
| 'repeat_refresh_media' => ['nullable', 'in:0,1'], |
| 'brand_label' => ['nullable', 'string', 'max:120'], |
| 'team_label' => ['nullable', 'string', 'max:120'], |
| 'approval_mode' => ['nullable', 'in:inherit,manual,auto'], |
| ]); |
|
|
| try { |
| $platformHint = $data['platform_hint'] ?? implode(', ', $data['platforms']); |
| $generated = $generator->generate( |
| $data['topic'], |
| $platformHint, |
| $data['tone'] ?? 'friendly-professional' |
| ); |
|
|
| $payload = array_merge($data, [ |
| 'title' => $generated['title'] ?? $data['topic'], |
| 'caption' => $this->composeGeneratedCaption($generated), |
| 'hashtags' => $generated['hashtags'] ?? [], |
| ]); |
| $payload = $this->prepareQueuedMedia($request->user()->id, $payload, $generated, $studio); |
|
|
| $validationMessage = $this->validateQueuedPostInput($payload); |
| if ($validationMessage !== null) { |
| return redirect()->route('social.index') |
| ->with('social_error', $validationMessage) |
| ->with('social_generated', $generated) |
| ->withInput(); |
| } |
|
|
| $campaign = $recurringCampaigns->createFromPayload($request->user(), $payload); |
| $this->queuePosts( |
| $request->user(), |
| $payload, |
| $campaign ? [ |
| 'recurrence_campaign_id' => $campaign->id, |
| 'recurrence_occurrence_at' => Carbon::parse($payload['scheduled_at']), |
| ] : [] |
| ); |
| } catch (\Throwable $e) { |
| return redirect()->route('social.index') |
| ->with('social_error', $e->getMessage()) |
| ->withInput(); |
| } |
|
|
| return redirect()->route('social.index') |
| ->with('social_generated', $generated) |
| ->with('social_success', 'Konten AI berhasil dibuat dan langsung masuk ke queue automation.'); |
| } |
|
|
| public function generateMediaImage(Request $request, SocialMediaStudioService $studio): JsonResponse |
| { |
| $data = $request->validate([ |
| 'prompt' => ['required', 'string', 'max:1000'], |
| ]); |
|
|
| try { |
| $media = $studio->generateImage($data['prompt']); |
| $assets = $this->saveGeneratedImageAssets($request->user()->id, $media); |
|
|
| return response()->json([ |
| 'media' => $media, |
| 'saved_assets' => $assets, |
| ]); |
| } catch (\Throwable $e) { |
| return response()->json([ |
| 'message' => $e->getMessage(), |
| ], 422); |
| } |
| } |
|
|
| public function generateMediaVideo(Request $request, SocialMediaStudioService $studio): JsonResponse |
| { |
| $data = $request->validate([ |
| 'prompt' => ['required', 'string', 'max:1000'], |
| ]); |
|
|
| try { |
| $media = $studio->generateVideo($data['prompt']); |
| $asset = $this->saveGeneratedVideoAsset($request->user()->id, $media); |
|
|
| return response()->json([ |
| 'media' => $media, |
| 'saved_assets' => [$asset], |
| ]); |
| } catch (\Throwable $e) { |
| return response()->json([ |
| 'message' => $e->getMessage(), |
| ], 422); |
| } |
| } |
|
|
| public function composeVideoMusic( |
| Request $request, |
| FreepikMusicService $music, |
| VideoMusicComposerService $composer |
| ): JsonResponse { |
| $data = $request->validate([ |
| 'video_url' => ['required', 'string', 'max:2048'], |
| 'music_prompt' => ['required', 'string', 'max:600'], |
| 'music_duration' => ['nullable', 'integer', 'min:5', 'max:60'], |
| 'music_volume' => ['nullable', 'numeric', 'min:0.05', 'max:1'], |
| ]); |
|
|
| if (! $this->isAcceptedMediaUrl($data['video_url'])) { |
| return response()->json([ |
| 'message' => 'Video URL harus berupa http/https atau path /storage/...', |
| ], 422); |
| } |
|
|
| try { |
| $track = $music->generate( |
| $data['music_prompt'], |
| (int) ($data['music_duration'] ?? 15) |
| ); |
|
|
| $mixed = $composer->compose( |
| $data['video_url'], |
| (string) ($track['url'] ?? ''), |
| (float) ($data['music_volume'] ?? 0.24) |
| ); |
|
|
| $asset = SocialMediaAsset::query()->create([ |
| 'user_id' => $request->user()->id, |
| 'type' => 'video', |
| 'provider' => 'freepik-music-mix', |
| 'prompt' => trim($data['music_prompt']), |
| 'url' => (string) $mixed['url'], |
| 'thumbnail_url' => null, |
| 'width' => 1080, |
| 'height' => 1920, |
| 'duration_seconds' => (int) ($track['duration_seconds'] ?? 0), |
| 'meta' => [ |
| 'source_video_url' => $data['video_url'], |
| 'music_url' => $track['url'] ?? null, |
| 'music_prompt' => $data['music_prompt'], |
| 'music_provider' => $track['provider'] ?? 'freepik-music', |
| 'music_duration' => (int) ($data['music_duration'] ?? 15), |
| 'music_volume' => (float) ($data['music_volume'] ?? 0.24), |
| ], |
| ]); |
|
|
| return response()->json([ |
| 'message' => 'BGM berhasil ditambahkan ke video.', |
| 'mixed_video_url' => $asset->url, |
| 'music' => $track, |
| 'saved_asset' => $this->assetPayload($asset), |
| ]); |
| } catch (\Throwable $e) { |
| return response()->json([ |
| 'message' => $e->getMessage(), |
| ], 422); |
| } |
| } |
|
|
| public function uploadMediaAsset(Request $request): JsonResponse |
| { |
| $data = $request->validate([ |
| 'asset' => ['required', 'file', 'max:51200', 'mimetypes:image/jpeg,image/png,image/webp,image/gif,video/mp4,video/quicktime,video/webm'], |
| 'label' => ['nullable', 'string', 'max:250'], |
| ]); |
|
|
| $file = $data['asset']; |
| $mime = (string) $file->getMimeType(); |
| $type = Str::startsWith($mime, 'video/') ? 'video' : 'image'; |
| $extension = strtolower((string) $file->getClientOriginalExtension()); |
| if ($extension === '') { |
| $extension = $type === 'video' ? 'mp4' : 'jpg'; |
| } |
|
|
| $folder = $type === 'video' ? 'social_assets/videos' : 'social_assets/images'; |
| $filename = now()->format('Ymd_His').'_'.$request->user()->id.'_'.Str::lower(Str::random(8)).'.'.$extension; |
| $storedPath = $file->storeAs($folder, $filename, 'public'); |
|
|
| $asset = SocialMediaAsset::query()->create([ |
| 'user_id' => $request->user()->id, |
| 'type' => $type, |
| 'provider' => 'upload', |
| 'prompt' => trim((string) ($data['label'] ?? '')), |
| 'url' => Storage::url($storedPath), |
| 'thumbnail_url' => $type === 'image' ? Storage::url($storedPath) : null, |
| 'meta' => [ |
| 'source' => 'manual_upload', |
| 'mime' => $mime, |
| 'original_name' => $file->getClientOriginalName(), |
| 'size_bytes' => $file->getSize(), |
| ], |
| ]); |
|
|
| return response()->json([ |
| 'message' => ucfirst($type).' berhasil diupload dan tersimpan di gallery.', |
| 'saved_asset' => $this->assetPayload($asset), |
| ]); |
| } |
|
|
| public function remixMedia(Request $request, SocialMediaStudioService $studio): JsonResponse |
| { |
| $data = $request->validate([ |
| 'source_asset_id' => ['required', 'integer'], |
| 'prompt' => ['required', 'string', 'max:1000'], |
| 'mode' => ['required', 'in:mockup,face_swap,restyle'], |
| 'output_type' => ['required', 'in:image,video'], |
| ]); |
|
|
| $source = SocialMediaAsset::query() |
| ->where('id', $data['source_asset_id']) |
| ->where('user_id', $request->user()->id) |
| ->first(); |
|
|
| if (! $source) { |
| return response()->json([ |
| 'message' => 'Source asset tidak ditemukan atau bukan milik akun ini.', |
| ], 404); |
| } |
|
|
| try { |
| $media = $studio->remixFromAsset([ |
| 'type' => $source->type, |
| 'url' => $source->url, |
| ], $data['prompt'], $data['mode'], $data['output_type']); |
|
|
| if ($data['output_type'] === 'video') { |
| $asset = $this->saveGeneratedVideoAsset($request->user()->id, $media); |
| return response()->json([ |
| 'media' => $media, |
| 'saved_assets' => [$asset], |
| ]); |
| } |
|
|
| $assets = $this->saveGeneratedImageAssets($request->user()->id, $media); |
| return response()->json([ |
| 'media' => $media, |
| 'saved_assets' => $assets, |
| ]); |
| } catch (\Throwable $e) { |
| return response()->json([ |
| 'message' => $e->getMessage(), |
| ], 422); |
| } |
| } |
|
|
| public function store( |
| Request $request, |
| SocialRecurringCampaignService $recurringCampaigns |
| ): 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', 'string', 'max:2048'], |
| 'video_url' => ['nullable', 'string', 'max:2048'], |
| 'bgm_auto' => ['nullable', 'in:0,1'], |
| 'bgm_prompt' => ['nullable', 'string', 'max:600'], |
| 'bgm_duration' => ['nullable', 'integer', 'min:5', 'max:60'], |
| 'bgm_volume' => ['nullable', 'numeric', 'min:0.05', 'max:1'], |
| 'scheduled_at' => ['required', 'date'], |
| 'status' => ['required', 'in:draft,scheduled'], |
| 'repeat_frequency' => ['nullable', 'in:none,daily,weekly'], |
| 'repeat_until' => ['nullable', 'date', 'after:scheduled_at'], |
| 'repeat_refresh_content' => ['nullable', 'in:0,1'], |
| 'repeat_refresh_media' => ['nullable', 'in:0,1'], |
| 'brand_label' => ['nullable', 'string', 'max:120'], |
| 'team_label' => ['nullable', 'string', 'max:120'], |
| 'approval_mode' => ['nullable', 'in:inherit,manual,auto'], |
| ]); |
|
|
| $validationMessage = $this->validateQueuedPostInput($data); |
| if ($validationMessage !== null) { |
| return redirect()->route('social.index') |
| ->with('social_error', $validationMessage) |
| ->withInput(); |
| } |
|
|
| $campaign = $recurringCampaigns->createFromPayload($request->user(), $data); |
|
|
| $this->queuePosts( |
| $request->user(), |
| $data, |
| $campaign ? [ |
| 'recurrence_campaign_id' => $campaign->id, |
| 'recurrence_occurrence_at' => Carbon::parse($data['scheduled_at']), |
| ] : [] |
| ); |
|
|
| 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 updateRecurringCampaign( |
| Request $request, |
| SocialRecurringCampaign $campaign, |
| SocialRecurringCampaignService $recurringCampaigns |
| ): RedirectResponse { |
| abort_unless($campaign->user_id === $request->user()->id, 403); |
|
|
| $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', 'string', 'max:2048'], |
| 'video_url' => ['nullable', 'string', 'max:2048'], |
| 'starts_at' => ['required', 'date'], |
| 'next_occurrence_at' => ['required', 'date'], |
| 'repeat_frequency' => ['required', 'in:daily,weekly'], |
| 'repeat_until' => ['nullable', 'date', 'after_or_equal:next_occurrence_at'], |
| 'topic' => ['nullable', 'string', 'max:350'], |
| 'tone' => ['nullable', 'string', 'max:100'], |
| 'platform_hint' => ['nullable', 'string', 'max:100'], |
| 'auto_generate_image' => ['nullable', 'in:0,1'], |
| 'auto_generate_video' => ['nullable', 'in:0,1'], |
| 'image_prompt' => ['nullable', 'string', 'max:1000'], |
| 'video_prompt' => ['nullable', 'string', 'max:1000'], |
| 'repeat_refresh_content' => ['nullable', 'in:0,1'], |
| 'repeat_refresh_media' => ['nullable', 'in:0,1'], |
| 'bgm_auto' => ['nullable', 'in:0,1'], |
| 'bgm_prompt' => ['nullable', 'string', 'max:600'], |
| 'bgm_duration' => ['nullable', 'integer', 'min:5', 'max:60'], |
| 'bgm_volume' => ['nullable', 'numeric', 'min:0.05', 'max:1'], |
| 'brand_label' => ['nullable', 'string', 'max:120'], |
| 'team_label' => ['nullable', 'string', 'max:120'], |
| 'approval_mode' => ['nullable', 'in:inherit,manual,auto'], |
| ]); |
|
|
| $validationMessage = $this->validateQueuedPostInput($data); |
| if ($validationMessage !== null) { |
| return redirect()->route('social.index') |
| ->with('social_error', $validationMessage) |
| ->withInput(); |
| } |
|
|
| $recurringCampaigns->updateCampaign($campaign, $data); |
|
|
| return redirect()->route('social.index')->with('social_success', 'Recurring campaign berhasil diperbarui.'); |
| } |
|
|
| public function pauseRecurringCampaign( |
| Request $request, |
| SocialRecurringCampaign $campaign, |
| SocialRecurringCampaignService $recurringCampaigns |
| ): RedirectResponse { |
| abort_unless($campaign->user_id === $request->user()->id, 403); |
|
|
| $recurringCampaigns->pause($campaign); |
|
|
| return redirect()->route('social.index')->with('social_success', 'Recurring campaign di-pause.'); |
| } |
|
|
| public function runRecurringCampaignNow( |
| Request $request, |
| SocialRecurringCampaign $campaign, |
| SocialRecurringCampaignService $recurringCampaigns |
| ): RedirectResponse { |
| abort_unless($campaign->user_id === $request->user()->id, 403); |
|
|
| $recurringCampaigns->enqueueCampaignNow($campaign); |
|
|
| return redirect()->route('social.index')->with('social_success', 'Recurring campaign langsung dibuatkan queue baru.'); |
| } |
|
|
| public function resumeRecurringCampaign( |
| Request $request, |
| SocialRecurringCampaign $campaign, |
| SocialRecurringCampaignService $recurringCampaigns |
| ): RedirectResponse { |
| abort_unless($campaign->user_id === $request->user()->id, 403); |
|
|
| $recurringCampaigns->resume($campaign); |
|
|
| return redirect()->route('social.index')->with('social_success', 'Recurring campaign aktif lagi.'); |
| } |
|
|
| public function destroyRecurringCampaign( |
| Request $request, |
| SocialRecurringCampaign $campaign |
| ): RedirectResponse { |
| abort_unless($campaign->user_id === $request->user()->id, 403); |
|
|
| $campaign->delete(); |
|
|
| return redirect()->route('social.index')->with('social_success', 'Recurring campaign berhasil dihapus.'); |
| } |
|
|
| public function dismissRecurringAlert( |
| Request $request, |
| SocialRecurringAlert $alert |
| ): RedirectResponse { |
| abort_unless($alert->user_id === $request->user()->id, 403); |
|
|
| $alert->update([ |
| 'read_at' => now(), |
| ]); |
|
|
| return redirect()->route('social.index')->with('social_success', 'Recurring alert ditandai selesai.'); |
| } |
|
|
| public function dismissAllRecurringAlerts(Request $request): RedirectResponse |
| { |
| $request->user()->socialRecurringAlerts() |
| ->whereNull('read_at') |
| ->update([ |
| 'read_at' => now(), |
| ]); |
|
|
| return redirect()->route('social.index')->with('social_success', 'Semua recurring alert sudah dibersihkan.'); |
| } |
|
|
| 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, |
| SocialPostMediaPreparationService $mediaPreparation |
| ): 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 { |
| if ($mediaPreparation->prepareForPublishing($post)) { |
| $post->refresh(); |
| } |
|
|
| $result = $publisher->publish($post, $credential); |
|
|
| $currentMeta = is_array($post->meta) ? $post->meta : []; |
| $currentMeta['publish_payload'] = $result['payload'] ?? []; |
|
|
| $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' => $currentMeta, |
| '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 saveGeneratedImageAssets(int $userId, array $media): array |
| { |
| $variants = collect($media['variants'] ?? []) |
| ->filter(fn ($item) => is_array($item) && filled($item['url'] ?? null)) |
| ->values(); |
|
|
| if ($variants->isEmpty() && filled($media['url'] ?? null)) { |
| $variants = collect([[ |
| 'url' => (string) $media['url'], |
| 'thumbnail_url' => (string) ($media['thumbnail_url'] ?? $media['url']), |
| 'width' => (int) ($media['width'] ?? 1080), |
| 'height' => (int) ($media['height'] ?? 1920), |
| ]]); |
| } |
|
|
| return $variants->map(function (array $variant, int $index) use ($userId, $media) { |
| $asset = SocialMediaAsset::query()->create([ |
| 'user_id' => $userId, |
| 'type' => 'image', |
| 'provider' => (string) ($media['provider'] ?? 'unknown'), |
| 'prompt' => (string) ($media['prompt'] ?? ''), |
| 'url' => (string) $variant['url'], |
| 'thumbnail_url' => (string) ($variant['thumbnail_url'] ?? $variant['url']), |
| 'width' => (int) ($variant['width'] ?? $media['width'] ?? 1080), |
| 'height' => (int) ($variant['height'] ?? $media['height'] ?? 1920), |
| 'meta' => [ |
| 'aspect' => $media['aspect'] ?? '9:16', |
| 'variant_index' => $index, |
| ], |
| ]); |
|
|
| return [ |
| 'id' => $asset->id, |
| 'type' => 'image', |
| 'url' => $asset->url, |
| 'thumbnail_url' => $asset->thumbnail_url, |
| 'provider' => $asset->provider, |
| 'prompt' => $asset->prompt, |
| 'width' => $asset->width, |
| 'height' => $asset->height, |
| 'created_at' => optional($asset->created_at)?->toIso8601String(), |
| ]; |
| })->all(); |
| } |
|
|
| private function saveGeneratedVideoAsset(int $userId, array $media): array |
| { |
| if (blank($media['url'] ?? null)) { |
| throw new \RuntimeException('Video URL kosong, jadi belum bisa disimpan ke gallery.'); |
| } |
|
|
| $asset = SocialMediaAsset::query()->create([ |
| 'user_id' => $userId, |
| 'type' => 'video', |
| 'provider' => (string) ($media['provider'] ?? 'unknown'), |
| 'prompt' => (string) ($media['prompt'] ?? ''), |
| 'url' => (string) ($media['url'] ?? ''), |
| 'thumbnail_url' => (string) ($media['source_image_url'] ?? ''), |
| 'width' => 1080, |
| 'height' => 1920, |
| 'duration_seconds' => (int) ($media['duration_seconds'] ?? 0), |
| 'meta' => [ |
| 'aspect' => $media['aspect'] ?? '9:16', |
| 'source_image_provider' => $media['source_image_provider'] ?? null, |
| ], |
| ]); |
|
|
| return [ |
| 'id' => $asset->id, |
| 'type' => 'video', |
| 'url' => $asset->url, |
| 'thumbnail_url' => $asset->thumbnail_url, |
| 'provider' => $asset->provider, |
| 'prompt' => $asset->prompt, |
| 'duration_seconds' => $asset->duration_seconds, |
| 'created_at' => optional($asset->created_at)?->toIso8601String(), |
| ]; |
| } |
|
|
| private function assetPayload(SocialMediaAsset $asset): array |
| { |
| return [ |
| 'id' => $asset->id, |
| 'type' => $asset->type, |
| 'url' => $asset->url, |
| 'thumbnail_url' => $asset->thumbnail_url ?: $asset->url, |
| 'provider' => $asset->provider, |
| 'prompt' => $asset->prompt, |
| 'width' => $asset->width, |
| 'height' => $asset->height, |
| 'duration_seconds' => $asset->duration_seconds, |
| 'created_at' => optional($asset->created_at)?->toIso8601String(), |
| ]; |
| } |
|
|
| private function composeGeneratedCaption(array $generated): string |
| { |
| $caption = trim((string) ($generated['caption'] ?? '')); |
| $cta = trim((string) ($generated['cta'] ?? '')); |
|
|
| if ($caption === '') { |
| return $cta; |
| } |
|
|
| if ($cta === '' || str_contains(mb_strtolower($caption), mb_strtolower($cta))) { |
| return $caption; |
| } |
|
|
| return trim($caption."\n\n".$cta); |
| } |
|
|
| private function prepareQueuedMedia( |
| int $userId, |
| array $payload, |
| array $generated, |
| SocialMediaStudioService $studio |
| ): array { |
| if (blank($payload['image_url'] ?? null) && ($payload['auto_generate_image'] ?? '0') === '1') { |
| $imagePrompt = $this->resolveAutoMediaPrompt( |
| $payload['image_prompt'] ?? null, |
| $payload, |
| $generated, |
| 'image' |
| ); |
| $imageMedia = $studio->generateImage($imagePrompt); |
| $imageAssets = $this->saveGeneratedImageAssets($userId, $imageMedia); |
| $payload['image_url'] = $imageAssets[0]['url'] ?? ($imageMedia['url'] ?? null); |
| } |
|
|
| if (blank($payload['video_url'] ?? null) && ($payload['auto_generate_video'] ?? '0') === '1') { |
| $videoPrompt = $this->resolveAutoMediaPrompt( |
| $payload['video_prompt'] ?? null, |
| $payload, |
| $generated, |
| 'video' |
| ); |
| $videoMedia = $studio->generateVideo($videoPrompt); |
| $videoAsset = $this->saveGeneratedVideoAsset($userId, $videoMedia); |
| $payload['video_url'] = $videoAsset['url'] ?? ($videoMedia['url'] ?? null); |
| } |
|
|
| return $payload; |
| } |
|
|
| private function resolveAutoMediaPrompt( |
| ?string $customPrompt, |
| array $payload, |
| array $generated, |
| string $type |
| ): string { |
| $customPrompt = trim((string) $customPrompt); |
| if ($customPrompt !== '') { |
| return $customPrompt; |
| } |
|
|
| $title = trim((string) ($payload['title'] ?? $generated['title'] ?? $payload['topic'] ?? '')); |
| $caption = trim((string) ($generated['caption'] ?? $payload['caption'] ?? '')); |
|
|
| if ($type === 'video') { |
| return trim($title.'. '.$caption.' Vertical social video 9:16, premium campaign motion, clean branding, engaging movement.'); |
| } |
|
|
| return trim($title.'. '.$caption.' Vertical social image 9:16, premium campaign visual, clean branding, modern healthcare technology.'); |
| } |
|
|
| private function validateQueuedPostInput(array $data): ?string |
| { |
| if (filled($data['image_url'] ?? null) && ! $this->isAcceptedMediaUrl((string) $data['image_url'])) { |
| return 'Image URL harus http/https atau path /storage/...'; |
| } |
|
|
| if (filled($data['video_url'] ?? null) && ! $this->isAcceptedMediaUrl((string) $data['video_url'])) { |
| return 'Video URL harus http/https atau path /storage/...'; |
| } |
|
|
| if (in_array('instagram', $data['platforms'] ?? [], true) && blank($data['image_url'] ?? null)) { |
| return 'Instagram membutuhkan image_url publik sebelum post bisa dibuat.'; |
| } |
|
|
| if (in_array('tiktok', $data['platforms'] ?? [], true) && blank($data['video_url'] ?? null)) { |
| return 'TikTok membutuhkan video_url publik sebelum post bisa dibuat.'; |
| } |
|
|
| return null; |
| } |
|
|
| private function queuePosts($user, array $data, array $extraAttributes = []): void |
| { |
| $hashtags = $this->normalizeHashtags($data['hashtags'] ?? []); |
| $setting = $this->settingFor($user->id); |
| $scheduledAt = Carbon::parse($data['scheduled_at']); |
| $bgmMeta = $this->buildBgmMeta($data); |
| $campaignApprovalMode = $this->resolveRecurringApprovalMode($data, $setting->approval_mode); |
| $recurringContext = [ |
| 'brand_label' => $this->normalizeLaneLabel($data['brand_label'] ?? null), |
| 'team_label' => $this->normalizeLaneLabel($data['team_label'] ?? null), |
| 'approval_mode' => $campaignApprovalMode, |
| ]; |
|
|
| foreach ($data['platforms'] as $platform) { |
| $requiresApproval = ($data['status'] === 'scheduled') && ($campaignApprovalMode === 'manual'); |
| $status = $requiresApproval ? 'draft' : $data['status']; |
|
|
| $user->socialPosts()->create(array_merge([ |
| '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(), |
| 'meta' => [ |
| 'bgm' => $bgmMeta, |
| 'recurring_context' => $recurringContext, |
| ], |
| ], $extraAttributes)); |
| } |
| } |
|
|
| private function normalizeHashtags(array|string|null $hashtags): array |
| { |
| $items = is_array($hashtags) |
| ? $hashtags |
| : explode(',', (string) $hashtags); |
|
|
| return collect($items) |
| ->map(fn ($item) => trim((string) $item)) |
| ->filter() |
| ->map(fn ($item) => ltrim($item, '#')) |
| ->values() |
| ->all(); |
| } |
|
|
| private function buildBgmMeta(array $data): array |
| { |
| return [ |
| 'enabled' => ($data['bgm_auto'] ?? '1') === '1', |
| 'provider' => 'freepik', |
| 'prompt' => trim((string) ($data['bgm_prompt'] ?? '')), |
| 'duration' => (int) ($data['bgm_duration'] ?? 15), |
| 'volume' => (float) ($data['bgm_volume'] ?? 0.24), |
| 'applied' => false, |
| ]; |
| } |
|
|
| private function resolveRecurringApprovalMode(array $data, string $defaultApprovalMode): string |
| { |
| $campaignMode = $this->normalizeApprovalMode($data['approval_mode'] ?? null); |
| $repeatFrequency = (string) ($data['repeat_frequency'] ?? 'none'); |
|
|
| if (! in_array($repeatFrequency, ['daily', 'weekly'], true)) { |
| return $defaultApprovalMode; |
| } |
|
|
| return $campaignMode === 'inherit' |
| ? $defaultApprovalMode |
| : $campaignMode; |
| } |
|
|
| private function normalizeApprovalMode(?string $approvalMode): string |
| { |
| $approvalMode = trim((string) $approvalMode); |
|
|
| return in_array($approvalMode, ['inherit', 'manual', 'auto'], true) |
| ? $approvalMode |
| : 'inherit'; |
| } |
|
|
| private function normalizeLaneLabel(?string $value): ?string |
| { |
| $value = trim((string) $value); |
|
|
| return $value !== '' ? Str::limit($value, 120, '') : null; |
| } |
|
|
| private function isAcceptedMediaUrl(string $url): bool |
| { |
| $url = trim($url); |
|
|
| if ($url === '') { |
| return false; |
| } |
|
|
| if (Str::startsWith($url, '/storage/')) { |
| return true; |
| } |
|
|
| return (bool) filter_var($url, FILTER_VALIDATE_URL); |
| } |
|
|
| 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', |
| ]; |
| } |
| } |
|
|