teknolis / app /Services /SocialRecurringCampaignService.php
Codex
Deploy latest assistant, DeepSeek defaults, and automation updates
74acf19
Raw
History Blame Contribute Delete
20.1 kB
<?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),
]]);
}
/** @var Collection<int, array> $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(),
]);
}
}