nextOccurrence($startsAt, $frequency); return SocialRecurringCampaign::query()->create([ 'user_id' => $user->id, 'platforms' => array_values($data['platforms'] ?? []), 'repeat_frequency' => $frequency, 'status' => $endsAt && $nextOccurrenceAt->gt($endsAt) ? 'completed' : 'active', 'brand_label' => $this->normalizeLaneLabel($data['brand_label'] ?? null), 'team_label' => $this->normalizeLaneLabel($data['team_label'] ?? null), 'approval_mode' => $this->normalizeApprovalMode($data['approval_mode'] ?? null), 'title' => $data['title'] ?? null, 'caption' => (string) ($data['caption'] ?? ''), 'hashtags' => $this->normalizeHashtags($data['hashtags'] ?? []), 'image_url' => $data['image_url'] ?? null, 'video_url' => $data['video_url'] ?? null, 'starts_at' => $startsAt, 'next_occurrence_at' => $endsAt && $nextOccurrenceAt->gt($endsAt) ? null : $nextOccurrenceAt, 'ends_at' => $endsAt, 'meta' => [ 'bgm' => $this->buildBgmMeta($data), 'generation' => $this->buildGenerationMeta($data), ], ]); } public function enqueueDueCampaigns(int $limit = 30, int $windowMinutes = 60): int { $processed = 0; $horizon = now()->addMinutes(max(1, $windowMinutes)); $campaigns = SocialRecurringCampaign::query() ->where('status', 'active') ->whereNotNull('next_occurrence_at') ->where('next_occurrence_at', '<=', $horizon) ->with('user') ->orderBy('next_occurrence_at') ->limit(max(1, $limit)) ->get(); foreach ($campaigns as $campaign) { $occurrenceAt = $campaign->next_occurrence_at; if (! $occurrenceAt) { continue; } if ($campaign->ends_at && $occurrenceAt->gt($campaign->ends_at)) { $campaign->update([ 'status' => 'completed', 'next_occurrence_at' => null, ]); continue; } $alreadyExists = $campaign->user->socialPosts() ->where('recurrence_campaign_id', $campaign->id) ->where('recurrence_occurrence_at', $occurrenceAt) ->exists(); try { if (! $alreadyExists) { $this->enqueueOccurrence($campaign, $occurrenceAt); $processed++; } $nextOccurrenceAt = $this->nextOccurrence($occurrenceAt, $campaign->repeat_frequency); $meta = is_array($campaign->meta) ? $campaign->meta : []; unset($meta['last_enqueue_error']); $campaign->update([ 'next_occurrence_at' => $campaign->ends_at && $nextOccurrenceAt->gt($campaign->ends_at) ? null : $nextOccurrenceAt, 'status' => $campaign->ends_at && $nextOccurrenceAt->gt($campaign->ends_at) ? 'completed' : 'active', 'meta' => $meta, ]); $this->markFailureAlertsResolved($campaign); } catch (\Throwable $e) { $meta = is_array($campaign->meta) ? $campaign->meta : []; $meta['last_enqueue_error'] = [ 'message' => $e->getMessage(), 'failed_at' => now()->toIso8601String(), ]; $campaign->update([ 'meta' => $meta, ]); $this->createFailureAlert($campaign, $occurrenceAt, $e); } } return $processed; } public function pause(SocialRecurringCampaign $campaign): void { $campaign->update([ 'status' => 'paused', ]); } public function resume(SocialRecurringCampaign $campaign): void { $nextOccurrenceAt = $campaign->next_occurrence_at ?: $this->nextOccurrence( $campaign->starts_at ?: now(), $campaign->repeat_frequency ); if ($campaign->ends_at && $nextOccurrenceAt->gt($campaign->ends_at)) { $campaign->update([ 'status' => 'completed', 'next_occurrence_at' => null, ]); return; } $campaign->update([ 'status' => 'active', 'next_occurrence_at' => $nextOccurrenceAt, ]); } public function updateCampaign(SocialRecurringCampaign $campaign, array $data): void { $meta = is_array($campaign->meta) ? $campaign->meta : []; $meta['bgm'] = $this->buildBgmMeta($data); $meta['generation'] = $this->buildGenerationMeta($data); $nextOccurrenceAt = Carbon::parse($data['next_occurrence_at']); $endsAt = filled($data['repeat_until'] ?? null) ? Carbon::parse((string) $data['repeat_until']) : null; $status = $campaign->status === 'completed' ? 'active' : $campaign->status; if ($endsAt && $nextOccurrenceAt->gt($endsAt)) { $status = 'completed'; $nextOccurrenceAt = null; } $campaign->update([ 'platforms' => array_values($data['platforms'] ?? []), 'repeat_frequency' => $data['repeat_frequency'], 'status' => $status, 'brand_label' => $this->normalizeLaneLabel($data['brand_label'] ?? null), 'team_label' => $this->normalizeLaneLabel($data['team_label'] ?? null), 'approval_mode' => $this->normalizeApprovalMode($data['approval_mode'] ?? null), 'title' => $data['title'] ?? null, 'caption' => (string) $data['caption'], 'hashtags' => $this->normalizeHashtags($data['hashtags'] ?? []), 'image_url' => $data['image_url'] ?? null, 'video_url' => $data['video_url'] ?? null, 'starts_at' => Carbon::parse($data['starts_at']), 'next_occurrence_at' => $nextOccurrenceAt, 'ends_at' => $endsAt, 'meta' => $meta, ]); } public function enqueueCampaignNow(SocialRecurringCampaign $campaign): void { $scheduledAt = now()->startOfMinute(); $this->enqueueOccurrence($campaign, $scheduledAt); } public function nextOccurrence(Carbon $from, string $frequency): Carbon { return match ($frequency) { 'daily' => $from->copy()->addDay(), 'weekly' => $from->copy()->addWeek(), default => $from->copy(), }; } private function enqueueOccurrence(SocialRecurringCampaign $campaign, Carbon $scheduledAt): void { $setting = $this->settingFor($campaign->user_id); $bgmMeta = is_array($campaign->meta) ? (array) ($campaign->meta['bgm'] ?? []) : []; $payload = $this->buildOccurrencePayload($campaign); $approvalMode = $this->resolveApprovalMode($campaign, $setting); $recurringContext = [ 'brand_label' => $campaign->brand_label, 'team_label' => $campaign->team_label, 'approval_mode' => $approvalMode, ]; foreach ((array) ($campaign->platforms ?? []) as $platform) { $requiresApproval = $approvalMode === 'manual'; $status = $requiresApproval ? 'draft' : 'scheduled'; $campaign->user->socialPosts()->create([ 'platform' => $platform, 'status' => $status, 'requires_approval' => $requiresApproval, 'title' => $payload['title'], 'caption' => $payload['caption'], 'hashtags' => $payload['hashtags'], 'image_url' => $payload['image_url'], 'video_url' => $payload['video_url'], 'scheduled_at' => $scheduledAt, 'approved_at' => $requiresApproval ? null : now(), 'meta' => [ 'bgm' => $bgmMeta, 'recurring_context' => $recurringContext, ], 'recurrence_campaign_id' => $campaign->id, 'recurrence_occurrence_at' => $scheduledAt, ]); } } private function buildOccurrencePayload(SocialRecurringCampaign $campaign): array { $generation = is_array($campaign->meta) ? (array) ($campaign->meta['generation'] ?? []) : []; $title = (string) ($campaign->title ?? ''); $caption = (string) ($campaign->caption ?? ''); $hashtags = is_array($campaign->hashtags) ? $campaign->hashtags : []; $imageUrl = $campaign->image_url; $videoUrl = $campaign->video_url; if (($generation['regenerate_content'] ?? false) && filled($generation['topic'] ?? null)) { $generated = $this->generator->generate( (string) $generation['topic'], (string) ($generation['platform_hint'] ?? implode(', ', (array) ($campaign->platforms ?? []))), (string) ($generation['tone'] ?? 'friendly-professional') ); $title = trim((string) ($generated['title'] ?? $title)); $caption = $this->composeGeneratedCaption($generated); $hashtags = $this->normalizeHashtags($generated['hashtags'] ?? []); } if (($generation['regenerate_media'] ?? false) && ($generation['auto_generate_image'] ?? false)) { $imagePrompt = $this->resolveAutoMediaPrompt( (string) ($generation['image_prompt'] ?? ''), $title, $caption, 'image' ); $imageMedia = $this->studio->generateImage($imagePrompt); $imageUrl = $this->persistGeneratedImage($campaign->user_id, $imageMedia); } if (($generation['regenerate_media'] ?? false) && ($generation['auto_generate_video'] ?? false)) { $videoPrompt = $this->resolveAutoMediaPrompt( (string) ($generation['video_prompt'] ?? ''), $title, $caption, 'video' ); $videoMedia = $this->studio->generateVideo($videoPrompt); $videoUrl = $this->persistGeneratedVideo($campaign->user_id, $videoMedia); } return [ 'title' => $title !== '' ? $title : null, 'caption' => $caption, 'hashtags' => $hashtags, 'image_url' => $imageUrl, 'video_url' => $videoUrl, ]; } 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 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 buildGenerationMeta(array $data): array { return [ 'topic' => trim((string) ($data['topic'] ?? '')), 'tone' => trim((string) ($data['tone'] ?? 'friendly-professional')), 'platform_hint' => trim((string) ($data['platform_hint'] ?? implode(', ', (array) ($data['platforms'] ?? [])))), 'regenerate_content' => ($data['repeat_refresh_content'] ?? '0') === '1', 'regenerate_media' => ($data['repeat_refresh_media'] ?? '0') === '1', 'auto_generate_image' => ($data['auto_generate_image'] ?? '0') === '1', 'auto_generate_video' => ($data['auto_generate_video'] ?? '0') === '1', 'image_prompt' => trim((string) ($data['image_prompt'] ?? '')), 'video_prompt' => trim((string) ($data['video_prompt'] ?? '')), ]; } 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 resolveAutoMediaPrompt(string $customPrompt, string $title, string $caption, string $type): string { $customPrompt = trim($customPrompt); if ($customPrompt !== '') { return $customPrompt; } 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 persistGeneratedImage(int $userId, array $media): ?string { $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), ]]); } /** @var Collection $variants */ return $variants->map(function (array $variant) 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', 'source' => 'recurring_campaign', ], ]); return (string) $asset->url; })->first(); } private function persistGeneratedVideo(int $userId, array $media): ?string { $url = (string) ($media['url'] ?? ''); if ($url === '') { return null; } $asset = SocialMediaAsset::query()->create([ 'user_id' => $userId, 'type' => 'video', 'provider' => (string) ($media['provider'] ?? 'unknown'), 'prompt' => (string) ($media['prompt'] ?? ''), 'url' => $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, 'source' => 'recurring_campaign', ], ]); return (string) $asset->url; } private function resolveApprovalMode(SocialRecurringCampaign $campaign, SocialAutomationSetting $setting): string { $campaignMode = $this->normalizeApprovalMode($campaign->approval_mode); return $campaignMode === 'inherit' ? $setting->approval_mode : $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 createFailureAlert(SocialRecurringCampaign $campaign, Carbon $occurrenceAt, \Throwable $e): void { $message = trim($e->getMessage()); if ($message === '') { $message = 'Unknown recurring campaign failure.'; } $duplicateExists = SocialRecurringAlert::query() ->where('user_id', $campaign->user_id) ->where('social_recurring_campaign_id', $campaign->id) ->where('type', 'enqueue_failed') ->whereNull('read_at') ->where('message', $message) ->where('created_at', '>=', now()->subHours(6)) ->exists(); if ($duplicateExists) { return; } SocialRecurringAlert::query()->create([ 'user_id' => $campaign->user_id, 'social_recurring_campaign_id' => $campaign->id, 'type' => 'enqueue_failed', 'title' => 'Recurring enqueue failed', 'message' => $message, 'context' => [ 'campaign_id' => $campaign->id, 'campaign_title' => $campaign->title, 'platforms' => $campaign->platforms, 'brand_label' => $campaign->brand_label, 'team_label' => $campaign->team_label, 'occurrence_at' => $occurrenceAt->toIso8601String(), ], ]); } private function markFailureAlertsResolved(SocialRecurringCampaign $campaign): void { SocialRecurringAlert::query() ->where('user_id', $campaign->user_id) ->where('social_recurring_campaign_id', $campaign->id) ->where('type', 'enqueue_failed') ->whereNull('read_at') ->update([ 'read_at' => now(), ]); } }