Download app/Http/Controllers/SocialAutomationController.php from medilis/teknolis: direct link, hf CLI and curl.
- Browser
- Download file 42.6 kB
-
https://huggingface.co/spaces/medilis/teknolis/resolve/main/app/Http/Controllers/SocialAutomationController.php
- Command line
-
hf download hf://spaces/medilis/teknolis/app/Http/Controllers/SocialAutomationController.php
-
curl -L -o SocialAutomationController.php https://huggingface.co/spaces/medilis/teknolis/resolve/main/app/Http/Controllers/SocialAutomationController.php
42.6 kB
| 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', | |
| ]; | |
| } | |
| } | |