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