| <?php |
|
|
| namespace App\Services; |
|
|
| use App\Models\SocialAutomationSetting; |
| use App\Models\SocialMediaAsset; |
| use App\Models\SocialRecurringAlert; |
| use App\Models\SocialRecurringCampaign; |
| use App\Models\User; |
| use Illuminate\Support\Carbon; |
| use Illuminate\Support\Collection; |
| use Illuminate\Support\Str; |
|
|
| class SocialRecurringCampaignService |
| { |
| public function __construct( |
| private SocialContentGeneratorService $generator, |
| private SocialMediaStudioService $studio |
| ) { |
| } |
|
|
| public function createFromPayload(User $user, array $data): ?SocialRecurringCampaign |
| { |
| $frequency = (string) ($data['repeat_frequency'] ?? 'none'); |
| if ($frequency === 'none') { |
| return null; |
| } |
|
|
| $startsAt = Carbon::parse($data['scheduled_at']); |
| $endsAt = filled($data['repeat_until'] ?? null) |
| ? Carbon::parse((string) $data['repeat_until']) |
| : null; |
| $nextOccurrenceAt = $this->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), |
| ]]); |
| } |
|
|
| |
| 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(), |
| ]); |
| } |
| } |
|
|