'array', 'subject' => 'array', 'scheduled_at' => 'datetime', 'sent_at' => 'datetime', 'filter_by_location' => 'boolean', 'filter_by_profile_type' => 'boolean', 'selected_profile_types' => 'array', ]; protected $dates = ['scheduled_at', 'sent_at', 'deleted_at']; /** * Get the user who last updated the mailing */ public function updatedByUser() { return $this->belongsTo(\App\Models\User::class, 'updated_by_user_id'); } /** * Get the bounces for this mailing */ public function bounces() { return $this->hasMany(\App\Models\MailingBounce::class, 'mailing_id'); } /** * Get the bounced count dynamically from the mailing_bounces table */ public function getBouncedCountAttribute() { return $this->bounces()->count(); } /** * Get posts from content_blocks JSON */ public function getSelectedPosts() { if (empty($this->content_blocks)) { return collect(); } $postIds = collect($this->content_blocks)->pluck('post_id'); return Post::whereIn('id', $postIds)->get()->sortBy(function ($post) { return array_search($post->id, collect($this->content_blocks)->pluck('post_id')->toArray()); }); } /** * Get posts with all their translations */ public function getSelectedPostsWithTranslations() { return $this->getSelectedPosts()->load('translations'); } /** * Get post translation for specific locale with fallback */ public function getPostTranslationForLocale($postId, $locale = null) { $locale = $locale ?: App::getLocale(); $post = Post::find($postId); if (!$post) { return null; } // Try preferred locale first $translation = $post->translations()->where('locale', $locale)->first(); // Only fallback if enabled in configuration if (!$translation && timebank_config('bulk_mail.use_fallback_locale', true)) { $fallbackLocale = timebank_config('bulk_mail.fallback_locale', 'en'); if ($locale !== $fallbackLocale) { // Avoid infinite loop $translation = $post->translations()->where('locale', $fallbackLocale)->first(); } } return $translation; } /** * Get recipients query based on mailing type */ public function getRecipientsQuery() { $queries = []; // Initialize all profile type queries $users = User::whereHas('messageSettings', function ($query) { $query->where($this->type, true); })->select('id', 'name', 'email', 'lang_preference'); $organizations = Organization::whereHas('messageSettings', function ($query) { $query->where($this->type, true); })->select('id', 'name', 'email', 'lang_preference'); $banks = Bank::whereHas('message_settings', function ($query) { $query->where($this->type, true); })->select('id', 'name', 'email', 'lang_preference'); $admins = Admin::whereHas('message_settings', function ($query) { $query->where($this->type, true); })->select('id', 'name', 'email', 'lang_preference'); // Apply profile type filtering if enabled if ($this->filter_by_profile_type && !empty($this->selected_profile_types)) { $selectedTypes = $this->selected_profile_types; // Only include selected profile types if (in_array('User', $selectedTypes)) { $queries[] = $users; } if (in_array('Organization', $selectedTypes)) { $queries[] = $organizations; } if (in_array('Bank', $selectedTypes)) { $queries[] = $banks; } if (in_array('Admin', $selectedTypes)) { $queries[] = $admins; } } else { // If no profile type filtering, include all types $queries = [$users, $organizations, $banks, $admins]; } // Apply location filtering if enabled if ($this->filter_by_location) { $queries = array_map(function($query) { return $this->applyLocationFilter($query); }, $queries); } // Get count before bounce filtering for logging $beforeFilteringCount = 0; foreach ($queries as $query) { $beforeFilteringCount += $query->count(); } // Apply bounce filtering to all queries $queries = array_map(function($query) { return $query->whereNotIn('email', function($subquery) { $subquery->select('email') ->from('mailing_bounces') ->where('is_suppressed', true); }); }, $queries); // Get count after bounce filtering for logging $afterFilteringCount = 0; foreach ($queries as $query) { $afterFilteringCount += $query->count(); } // Log suppression statistics if any emails were excluded $suppressedCount = $beforeFilteringCount - $afterFilteringCount; if ($suppressedCount > 0) { Log::warning("MAILING RECIPIENTS: Excluded {$suppressedCount} suppressed emails from Mailing ID: {$this->id}"); // Log which specific emails were suppressed $suppressedEmails = MailingBounce::where('is_suppressed', true)->pluck('email'); foreach ($suppressedEmails as $email) { Log::warning("MAILING RECIPIENTS: Suppressed email excluded: {$email}"); } } // Combine all queries with union if (empty($queries)) { // Return empty query if no profile types selected return User::whereRaw('1 = 0')->select('id', 'name', 'email', 'lang_preference'); } $finalQuery = array_shift($queries); foreach ($queries as $query) { $finalQuery = $finalQuery->union($query); } return $finalQuery; } /** * Apply location filtering to a query */ private function applyLocationFilter($query) { return $query->whereHas('locations', function ($locationQuery) { if ($this->location_district_id) { $locationQuery->where('district_id', $this->location_district_id); } elseif ($this->location_city_id) { $locationQuery->where('city_id', $this->location_city_id); } elseif ($this->location_division_id) { $locationQuery->where('division_id', $this->location_division_id); } elseif ($this->location_country_id) { $locationQuery->where('country_id', $this->location_country_id); } }); } /** * Get recipients grouped by language preference for efficient processing */ public function getRecipientsByLanguage() { $recipients = $this->getRecipientsQuery()->get(); return $recipients->groupBy(function ($recipient) { return $recipient->lang_preference ?: timebank_config('base_language', 'en'); }); } /** * Status checking methods */ public function canBeSent() { $hasValidStatus = in_array($this->status, ['draft', 'scheduled']); $hasPosts = !empty($this->content_blocks) && count(array_filter($this->content_blocks, function($block) { return !empty($block['post_id']); })) > 0; return $hasValidStatus && $hasPosts; } public function canBeScheduled() { return in_array($this->status, ['draft']); } public function canBeCancelled() { return in_array($this->status, ['scheduled', 'sending']); } /** * Scopes */ public function scopeDraft($query) { return $query->where('status', 'draft'); } public function scopeScheduled($query) { return $query->where('status', 'scheduled'); } public function scopeSent($query) { return $query->where('status', 'sent'); } public function scopeByType($query, $type) { return $query->where('type', $type); } /** * Get available locales for recipients of this mailing type */ public function getAvailableRecipientLocales() { $recipientsQuery = $this->getRecipientsQuery(); return $recipientsQuery ->whereNotNull('lang_preference') ->distinct() ->pluck('lang_preference') ->filter() ->toArray(); } /** * Get available locales from selected posts */ public function getAvailablePostLocales() { if (empty($this->content_blocks)) { return []; } $postIds = collect($this->content_blocks)->pluck('post_id'); $locales = \DB::table('post_translations') ->whereIn('post_id', $postIds) ->where('status', 1) // Only published translations ->distinct() ->pluck('locale') ->toArray(); // Always include base language first $baseLanguage = timebank_config('base_language', 'en'); $locales = array_unique(array_merge([$baseLanguage], $locales)); return array_values($locales); } /** * Get subject for a specific locale */ public function getSubjectForLocale($locale = null) { $locale = $locale ?: timebank_config('base_language', 'en'); $baseLanguage = timebank_config('base_language', 'en'); // Handle legacy string subjects if (is_string($this->subject)) { return $this->subject; } // Handle multilingual JSON subjects $subjects = $this->subject ?? []; // Try preferred locale first if (isset($subjects[$locale])) { return $subjects[$locale]; } // Fallback to base language if (isset($subjects[$baseLanguage])) { return $subjects[$baseLanguage]; } // Fallback to first available subject return !empty($subjects) ? array_values($subjects)[0] : ''; } /** * Set subject for a specific locale */ public function setSubjectForLocale($locale, $subject) { $subjects = $this->subject ?? []; $subjects[$locale] = $subject; $this->subject = $subjects; } /** * Get all subjects with their locales */ public function getAllSubjects() { return $this->subject ?? []; } /** * Check if subject exists for a locale */ public function hasSubjectForLocale($locale) { $subjects = $this->subject ?? []; return isset($subjects[$locale]) && !empty($subjects[$locale]); } /** * Get recipients for a specific locale */ public function getRecipientsForLocale($locale) { return $this->getRecipientsQuery() ->where('lang_preference', $locale); } /** * Get recipients grouped by available locales with fallback handling */ public function getRecipientsGroupedByAvailableLocales() { $availableLocales = $this->getAvailablePostLocales(); $allRecipients = $this->getRecipientsQuery()->get(); $recipientsByLocale = []; foreach ($allRecipients as $recipient) { $preferredLocale = $recipient->lang_preference ?? timebank_config('base_language', 'en'); // Check if preferred locale has content if (in_array($preferredLocale, $availableLocales)) { $recipientsByLocale[$preferredLocale][] = $recipient; } else { // Handle fallback logic if (timebank_config('bulk_mail.use_fallback_locale', true)) { $fallbackLocale = timebank_config('bulk_mail.fallback_locale', 'en'); if (in_array($fallbackLocale, $availableLocales)) { $recipientsByLocale[$fallbackLocale][] = $recipient; } } // If fallback is disabled or not available, recipient is skipped } } return $recipientsByLocale; } /** * Filter content blocks to only include posts with published translations in the given locale */ public function getContentBlocksForLocale($locale) { if (empty($this->content_blocks)) { return []; } $now = now(); $filteredBlocks = []; foreach ($this->content_blocks as $block) { $post = Post::with('translations')->find($block['post_id']); if (!$post) { continue; // Skip if post doesn't exist } // Check if post has a published translation in the requested locale $hasPublishedTranslation = $post->translations->contains(function ($translation) use ($locale, $now) { return $translation->locale === $locale && $translation->status == 1 && $translation->from <= $now && ($translation->till === null || $translation->till >= $now); }); if ($hasPublishedTranslation) { $filteredBlocks[] = $block; } } return $filteredBlocks; } /** * Get recipient count grouped by locale with content availability */ public function getRecipientCountsByLocale() { // Get all recipients using the same logic as getRecipientsQuery() $recipients = $this->getRecipientsQuery()->get(); // Get base language $baseLanguage = timebank_config('base_language', 'en'); // Group recipients by language preference (with NULL fallback to base language) $recipientsByLocale = $recipients->groupBy(function ($recipient) use ($baseLanguage) { return $recipient->lang_preference ?: $baseLanguage; }); // Add content block information for each locale $result = []; foreach ($recipientsByLocale as $locale => $localeRecipients) { $contentBlocks = $this->getContentBlocksForLocale($locale); $result[$locale] = [ 'count' => $localeRecipients->count(), 'content_blocks' => count($contentBlocks), 'has_content' => !empty($contentBlocks) ]; } return $result; } /** * Get formatted recipient counts display */ public function getFormattedRecipientCounts() { $counts = $this->getRecipientCountsByLocale(); $formatted = []; foreach ($counts as $locale => $data) { $language = \App\Models\Language::where('lang_code', $locale)->first(); $flag = $language ? $language->flag : '🏳️'; if ($data['has_content']) { $formatted[] = "{$flag} {$data['count']}"; } else { $formatted[] = "{$flag} {$data['count']} (no content)"; } } return implode(' ', $formatted); } /** * Get total recipients that will actually receive content */ public function getEffectiveRecipientsCount() { $counts = $this->getRecipientCountsByLocale(); return collect($counts) ->where('has_content', true) ->sum('count'); } /** * Dispatch locale-specific email jobs for this mailing */ public function dispatchLocaleSpecificJobs() { Log::info("MAILING: Starting dispatch process for Mailing ID: {$this->id}"); // Get batch size from configuration $batchSize = timebank_config('mailing.batch_size', 10); // Get recipients grouped by available locales with fallback logic $recipientsByLocale = $this->getRecipientsGroupedByAvailableLocales(); $totalJobsDispatched = 0; foreach ($recipientsByLocale as $locale => $recipients) { $contentBlocks = $this->getContentBlocksForLocale($locale); if (!empty($contentBlocks) && !empty($recipients)) { // Split recipients into batches $recipientBatches = collect($recipients)->chunk($batchSize); $batchCount = $recipientBatches->count(); Log::info("MAILING: Dispatching {$batchCount} batch job(s) for Mailing ID: {$this->id}, Locale: {$locale}, Total Recipients: " . count($recipients) . ", Batch Size: {$batchSize}"); // Dispatch a separate job for each batch foreach ($recipientBatches as $batchIndex => $batch) { $batchNumber = $batchIndex + 1; Log::info("MAILING: Dispatching batch {$batchNumber}/{$batchCount} for Mailing ID: {$this->id}, Locale: {$locale}, Recipients in batch: " . $batch->count()); // Dispatch the actual job with batch of recipients to dedicated mailing queue \App\Jobs\SendBulkMailJob::dispatch($this, $locale, [], $batch) ->onQueue('mailing'); $totalJobsDispatched++; } } } // Calculate actual recipients who will receive emails $totalRecipients = $this->getRecipientsQuery()->count(); $processedRecipients = collect($recipientsByLocale)->sum(function($recipients) { return count($recipients); }); // Update recipients_count to reflect actual sendable recipients $this->update(['recipients_count' => $processedRecipients]); // Get suppressed email count for logging $suppressedCount = MailingBounce::where('is_suppressed', true)->count(); Log::info("MAILING: Completed dispatch for Mailing ID: {$this->id} - Jobs dispatched: {$totalJobsDispatched}, Locales: " . count($recipientsByLocale) . ", Recipients processed: {$processedRecipients}, Suppressed emails: {$suppressedCount}"); // If no jobs were dispatched (no recipients or no content), mark as sent immediately if (empty($recipientsByLocale) || $processedRecipients === 0) { Log::warning("MAILING: No jobs dispatched for Mailing ID: {$this->id} - marking as sent immediately"); $this->update([ 'status' => 'sent', 'sent_at' => now() ]); } } }