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', ]; } }