'array', 'subject' => 'array', 'scheduled_at' => 'datetime', 'sent_at' => 'datetime', 'sending_started_at' => 'datetime', 'filter_by_location' => 'boolean', 'location_country_ids' => 'array', 'location_division_ids' => 'array', 'location_city_ids' => 'array', 'location_district_ids' => 'array', 'filter_by_profile_type' => 'boolean', 'selected_profile_types' => 'array', 'filter_by_contacts' => 'boolean', 'contact_profile_ids' => 'array', ]; protected $dates = ['scheduled_at', 'sent_at', 'sending_started_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(); } /** * Bounce stats for this mailing recorded after sending (SMTP DSN bounces). * These are distinct from skipped_count (pre-send suppression). */ public function getBounceStats(): array { $bounces = $this->bounces()->get(); return [ 'hard' => $bounces->where('bounce_type', 'hard')->count(), 'soft' => $bounces->where('bounce_type', 'soft')->count(), 'complaint' => $bounces->where('bounce_type', 'complaint')->count(), 'unknown' => $bounces->whereNull('bounce_type')->count(), 'total' => $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', 'meeting.location.city.translations', 'poll']); } /** * Get post translation for specific locale with fallback */ public function getPostTranslationForLocale($postId, $locale = null) { $locale = $locale ?: App::getLocale(); $now = now(); $post = Post::find($postId); if (!$post) { return null; } // Try preferred locale first $translation = $post->translations() ->where('locale', $locale) ->where('status', 1) ->where('from', '<=', $now) ->where(fn($q) => $q->whereNull('till')->orWhere('till', '>=', $now)) ->first(); // Fall back to mailing's configured fallback locale if set if (!$translation && $this->fallback_locale && $locale !== $this->fallback_locale) { $translation = $post->translations() ->where('locale', $this->fallback_locale) ->where('status', 1) ->where('from', '<=', $now) ->where(fn($q) => $q->whereNull('till')->orWhere('till', '>=', $now)) ->first(); } return $translation; } /** * Apply the message_settings subscription filter for this mailing type. * general_newsletter_only requires general_newsletter=true AND local_newsletter=false. */ protected function applySubscriptionFilter($query): void { if ($this->type === 'general_newsletter_only') { $query->where('general_newsletter', true)->where('local_newsletter', false); } else { $query->where($this->type, true); } } /** * Get recipients query based on mailing type */ public function getRecipientsQuery() { $queries = []; // Initialize all profile type queries — exclude soft-deleted profiles $users = User::whereNull('deleted_at')->whereHas('message_settings', function ($query) { $this->applySubscriptionFilter($query); })->select('id', 'name', 'email', 'lang_preference'); $organizations = Organization::whereNull('deleted_at')->whereHas('message_settings', function ($query) { $this->applySubscriptionFilter($query); })->select('id', 'name', 'email', 'lang_preference'); $banks = Bank::whereNull('deleted_at')->whereHas('message_settings', function ($query) { $this->applySubscriptionFilter($query); })->select('id', 'name', 'email', 'lang_preference'); $admins = Admin::whereNull('deleted_at')->whereHas('message_settings', function ($query) { $this->applySubscriptionFilter($query); })->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); } // Apply contact filtering if enabled if ($this->filter_by_contacts && !empty($this->contact_profile_ids)) { $queries = array_map(fn($q) => $this->applyContactFilter($q), $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); } // Order by email for deterministic, stable ordering across pause/resume cycles. // Deduplication by email is handled in getRecipientsCount() and // getRecipientsGroupedByAvailableLocales() — not here, so callers can // freely chain additional where/pluck/distinct constraints. return $finalQuery->orderBy('email'); } /** * Count unique recipient email addresses. * ->count() on a union builder misaligns PDO bindings in Laravel, so we * wrap the union in a subquery and count that instead. */ public function getRecipientsCount(): int { $union = $this->getRecipientsQuery(); $sql = $union->toSql(); $bindings = $union->getBindings(); return DB::table(DB::raw("({$sql}) as recipients_union")) ->addBinding($bindings, 'where') ->distinct() ->count('email'); } /** * Count recipients that match all active filters (type, profile type, location) * but are skipped because their email is in the hard-bounce suppression list. * This is the correct "Skipped (hard bounce)" number for the preview/report, * as opposed to the global suppression list size which ignores all filters. */ public function getSuppressedRecipientsCount(): int { $queries = []; $users = User::whereNull('deleted_at')->whereHas('message_settings', function ($q) { $this->applySubscriptionFilter($q); })->select('id', 'name', 'email', 'lang_preference'); $organizations = Organization::whereNull('deleted_at')->whereHas('message_settings', function ($q) { $this->applySubscriptionFilter($q); })->select('id', 'name', 'email', 'lang_preference'); $banks = Bank::whereNull('deleted_at')->whereHas('message_settings', function ($q) { $this->applySubscriptionFilter($q); })->select('id', 'name', 'email', 'lang_preference'); $admins = Admin::whereNull('deleted_at')->whereHas('message_settings', function ($q) { $this->applySubscriptionFilter($q); })->select('id', 'name', 'email', 'lang_preference'); if ($this->filter_by_profile_type && !empty($this->selected_profile_types)) { $selectedTypes = $this->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 { $queries = [$users, $organizations, $banks, $admins]; } if ($this->filter_by_location) { $queries = array_map(fn($q) => $this->applyLocationFilter($q), $queries); } if ($this->filter_by_contacts && !empty($this->contact_profile_ids)) { $queries = array_map(fn($q) => $this->applyContactFilter($q), $queries); } // Keep only those whose email IS suppressed (inverse of getRecipientsQuery) $queries = array_map(function ($q) { return $q->whereIn('email', function ($sub) { $sub->select('email')->from('mailing_bounces')->where('is_suppressed', true); }); }, $queries); if (empty($queries)) { return 0; } $final = array_shift($queries); foreach ($queries as $q) { $final = $final->union($q); } // ->count() on a union builder misaligns bindings; fetch and count in PHP instead. return $final->get()->count(); } /** * Apply location filtering to a query. * Reads from the array columns (location_country_ids, etc.) first. * Falls back to singular columns for records created before the multi-select migration. * * Matching is OR across all selected levels: a recipient matches if their * location falls under any selected country, division, city, or district. * This respects the hierarchy — selecting a country includes all recipients * in that country regardless of whether their city/district is also selected. */ /** * Get account IDs of all profiles that have had a transaction with any of the * contact_profile_ids. Returns account IDs belonging to the *partner* side. */ private function getContactPartnerAccountIds(): array { if (empty($this->contact_profile_ids)) { return []; } // Resolve the account IDs of the selected contact profiles $selectedAccountIds = collect($this->contact_profile_ids) ->map(function ($entry) { if (empty($entry['id']) || empty($entry['type'])) { return null; } return DB::table('accounts') ->where('accountable_type', $entry['type']) ->where('accountable_id', $entry['id']) ->whereNull('deleted_at') ->pluck('id'); }) ->filter() ->flatten() ->unique() ->values() ->all(); if (empty($selectedAccountIds)) { return []; } // Find all account IDs that transacted with the selected accounts (either direction) $fromIds = DB::table('transactions') ->whereIn('to_account_id', $selectedAccountIds) ->pluck('from_account_id'); $toIds = DB::table('transactions') ->whereIn('from_account_id', $selectedAccountIds) ->pluck('to_account_id'); return $fromIds->merge($toIds) ->merge($selectedAccountIds) // include the selected profiles themselves ->unique() ->values() ->all(); } private function applyContactFilter($query) { if (!$this->filter_by_contacts || empty($this->contact_profile_ids)) { return $query; } $partnerAccountIds = $this->getContactPartnerAccountIds(); if (empty($partnerAccountIds)) { return $query->whereRaw('1 = 0'); } return $query->whereHas('accounts', function ($q) use ($partnerAccountIds) { $q->whereIn('id', $partnerAccountIds); }); } private function applyLocationFilter($query) { $districtIds = !empty($this->location_district_ids) ? $this->location_district_ids : ($this->location_district_id ? [$this->location_district_id] : []); $cityIds = !empty($this->location_city_ids) ? $this->location_city_ids : ($this->location_city_id ? [$this->location_city_id] : []); $divisionIds = !empty($this->location_division_ids) ? $this->location_division_ids : ($this->location_division_id ? [$this->location_division_id] : []); $countryIds = !empty($this->location_country_ids) ? $this->location_country_ids : ($this->location_country_id ? [$this->location_country_id] : []); // Deepest selected level wins — see LocationFilterResolver. return \App\Services\Stats\LocationFilterResolver::apply( $query, $countryIds, $divisionIds, $cityIds, $districtIds ); } /** * 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 canBeEdited(): bool { return in_array($this->status, ['draft', 'scheduled']); } 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 locales that have a published, active translation for every selected post. * These are the only locales suitable as a fallback (a fallback locale that is * missing any post would still leave gaps in the mailing). */ public function getFallbackEligibleLocales(): array { if (empty($this->content_blocks)) { return []; } $now = now(); $postIds = collect($this->content_blocks)->pluck('post_id')->unique()->values(); $postCount = $postIds->count(); if ($postCount === 0) { return []; } // Count how many posts in the selection have a published active translation per locale $localeCoverage = \DB::table('post_translations') ->whereIn('post_id', $postIds) ->where('status', 1) ->where('from', '<=', $now) ->where(function ($q) use ($now) { $q->whereNull('till')->orWhere('till', '>=', $now); }) ->groupBy('locale') ->select('locale', \DB::raw('COUNT(DISTINCT post_id) as covered')) ->get(); return $localeCoverage ->filter(fn($row) => (int) $row->covered >= $postCount) ->pluck('locale') ->values() ->toArray(); } /** * Get available locales from selected posts */ public static function determinePostType($post): string { if ($post->category && $post->category->type) { if ($post->category->type === 'App\\Models\\Poll') { return 'poll'; } if (str_starts_with($post->category->type, 'App\\Models\\ImagePost')) { return 'image'; } if ($post->category->type === 'App\\Models\\Meeting') { return 'event'; } if ($post->category->type === 'App\\Models\\News') { return 'news'; } } // Fall back to meeting / poll relation if ($post->meeting) { return 'event'; } if ($post->poll) { return 'poll'; } return 'news'; } /** * Email template of a content block type. Falls back to the default block view, so a type * added later (e.g. poll) works before config:merge has added its key to the active config. */ public static function blockTemplate(string $postType): string { return timebank_config("mailing.templates.{$postType}_block") ?? "emails.newsletter.blocks.{$postType}"; } /** * Extra data for a poll block: link to the poll page (not the post page) and its state * at the time the email is built. Empty for posts without a poll. */ public static function pollBlockData($post, string $locale): array { $poll = $post->poll; if (!$poll) { return []; } $slug = $post->translations->firstWhere('locale', $locale)?->slug ?? $post->translations->first()?->slug; return [ 'url' => LaravelLocalization::getURLFromRouteNameTranslated($locale, 'routes.poll.show', ['slug' => $slug]), 'poll_closed' => $poll->isClosed(), 'poll_opens' => ($poll->opens_at && $poll->opens_at->isFuture()) ? $poll->opens_at->locale($locale)->isoFormat('LLL') : null, 'poll_closes' => $poll->closes_at ? $poll->closes_at->locale($locale)->isoFormat('LLL') : null, ]; } public function resolvePreviewLocale(): string { $appLocale = app()->getLocale(); if (!empty($this->getContentBlocksForLocale($appLocale))) { return $appLocale; } if ($this->type) { $countsByLocale = $this->getRecipientCountsByLocale(); $best = collect($countsByLocale) ->filter(fn($d) => $d['has_content']) ->sortByDesc('count') ->keys() ->first(); if ($best) { return $best; } } foreach ($this->getAvailablePostLocales() as $locale) { if (!empty($this->getContentBlocksForLocale($locale))) { return $locale; } } return timebank_config('base_language', 'en'); } 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. * When fallback_locale is set on the mailing, recipients whose preferred * locale has no content are grouped under their preferred locale key so * that they still receive a mail (with fallback content for missing posts). */ public function getRecipientsGroupedByAvailableLocales() { // Deduplicate by email in PHP: a person with multiple profiles (User + Org) // sharing the same address must only receive one email. $seenEmails = []; $allRecipients = $this->getRecipientsQuery()->get()->filter(function ($r) use (&$seenEmails) { if (isset($seenEmails[$r->email])) return false; $seenEmails[$r->email] = true; return true; }); $baseLanguage = timebank_config('base_language', 'en'); $recipientsByLocale = []; // Cache content block results per locale to avoid repeated DB queries $contentBlockCache = []; foreach ($allRecipients as $recipient) { $preferredLocale = $recipient->lang_preference ?? $baseLanguage; if (!array_key_exists($preferredLocale, $contentBlockCache)) { $contentBlockCache[$preferredLocale] = $this->getContentBlocksForLocale($preferredLocale); } if (!empty($contentBlockCache[$preferredLocale])) { $recipientsByLocale[$preferredLocale][] = $recipient; } } return $recipientsByLocale; } /** * Filter content blocks to only include posts with published translations in the given locale. * When a fallback_locale is set on this mailing, posts missing the requested locale are * included using the fallback, and the block is annotated with 'uses_fallback_locale'. */ public function getContentBlocksForLocale($locale) { if (empty($this->content_blocks)) { return []; } $now = now(); $filteredBlocks = []; foreach ($this->content_blocks as $block) { $post = Post::withTrashed()->with('translations')->find($block['post_id']); if (!$post) { $annotated = $block; $annotated['post_missing'] = true; $filteredBlocks[] = $annotated; continue; } $hasInLocale = $post->translations->contains(function ($t) use ($locale, $now) { return $t->locale === $locale && $t->status == 1 && $t->from <= $now && ($t->till === null || $t->till >= $now); }); if ($hasInLocale) { $filteredBlocks[] = $block; } elseif ($this->fallback_locale && $this->fallback_locale !== $locale) { $hasInFallback = $post->translations->contains(function ($t) use ($now) { return $t->locale === $this->fallback_locale && $t->status == 1 && $t->from <= $now && ($t->till === null || $t->till >= $now); }); if ($hasInFallback) { $annotated = $block; $annotated['uses_fallback_locale'] = $this->fallback_locale; $filteredBlocks[] = $annotated; } } } return $filteredBlocks; } /** * Get recipient count grouped by locale with content availability. * Each entry includes 'fallback_posts' — the number of posts that will be * served in the fallback locale instead of the recipient's own locale. */ public function getRecipientCountsByLocale() { $recipients = $this->getRecipientsQuery()->get(); $baseLanguage = timebank_config('base_language', 'en'); $recipientsByLocale = $recipients->groupBy(function ($recipient) use ($baseLanguage) { return $recipient->lang_preference ?: $baseLanguage; }); $totalPosts = count($this->content_blocks ?? []); $result = []; foreach ($recipientsByLocale as $locale => $localeRecipients) { $contentBlocks = $this->getContentBlocksForLocale($locale); $receivedCount = count($contentBlocks); $fallbackCount = collect($contentBlocks)->filter(fn($b) => !empty($b['uses_fallback_locale']))->count(); $result[$locale] = [ 'count' => $localeRecipients->count(), 'content_blocks' => $receivedCount, 'has_content' => $receivedCount > 0, 'all_posts' => $totalPosts > 0 && $receivedCount >= $totalPosts, 'fallback_posts' => $fallbackCount, 'missing_posts' => max(0, $totalPosts - $receivedCount), ]; } return $result; } /** * Get recipient counts grouped by locale AND smallest available location (using getLocationFirst). * Always groups by location regardless of whether a location filter is active. * Returns array keyed by "locale|location_name", each entry has locale, location, count, and post counts. */ public function getRecipientCountsByLocaleAndLocation(): array { $baseLanguage = timebank_config('base_language', 'en'); $totalPosts = count($this->content_blocks ?? []); // Load all recipients per model type with locations eager-loaded $locationsWith = ['locations.city.translations', 'locations.district.translations', 'locations.division.translations', 'locations.country.translations']; $modelTypes = [ 'User' => User::class, 'Organization' => Organization::class, 'Bank' => Bank::class, 'Admin' => Admin::class, ]; $allRecipients = collect(); foreach ($modelTypes as $type => $modelClass) { // Skip types not in filter if ($this->filter_by_profile_type && !empty($this->selected_profile_types)) { if (!in_array($type, $this->selected_profile_types)) { continue; } } $query = $modelClass::whereNull('deleted_at')->whereHas('message_settings', function ($q) { $this->applySubscriptionFilter($q); })->whereNotIn('email', function ($sub) { $sub->select('email')->from('mailing_bounces')->where('is_suppressed', true); })->with($locationsWith); if ($this->filter_by_location) { $query = $this->applyLocationFilter($query); } if ($this->filter_by_contacts && !empty($this->contact_profile_ids)) { $query = $this->applyContactFilter($query); } $allRecipients = $allRecipients->concat($query->get()); } // Group by locale + location name $result = []; foreach ($allRecipients as $recipient) { $locale = $recipient->lang_preference ?: $baseLanguage; $locationData = $recipient->getLocationFirst(); $locationName = $locationData['name_short'] ?? ''; $countryName = $locationData['name'] ?? ''; // Extract the country portion: it's the last comma-separated segment of the full name $countrySort = trim(last(explode(',', $countryName))); $key = $locale . '|' . $locationName; if (!isset($result[$key])) { $contentBlocks = $this->getContentBlocksForLocale($locale); $receivedCount = count($contentBlocks); $fallbackCount = collect($contentBlocks)->filter(fn($b) => !empty($b['uses_fallback_locale']))->count(); $result[$key] = [ 'locale' => $locale, 'location' => $locationName, 'country_sort' => $countrySort, 'count' => 0, 'content_blocks' => $receivedCount, 'has_content' => $receivedCount > 0, 'all_posts' => $totalPosts > 0 && $receivedCount >= $totalPosts, 'fallback_posts' => $fallbackCount, 'missing_posts' => max(0, $totalPosts - $receivedCount), ]; } $result[$key]['count']++; } // Sort by locale, then country, then location name uasort($result, function ($a, $b) { return $a['locale'] <=> $b['locale'] ?: $a['country_sort'] <=> $b['country_sort'] ?: $a['location'] <=> $b['location']; }); return $result; } /** * Get the most specific active location filter name. * Returns the name at the smallest level set: district > city > division > country. */ public function getLocationFilterName(): ?string { if (!$this->filter_by_location) { return null; } if ($this->location_district_id) { $locale = \App\Models\Locations\DistrictLocale::where('district_id', $this->location_district_id)->first(); return $locale?->name; } if ($this->location_city_id) { $locale = \App\Models\Locations\CityLocale::where('city_id', $this->location_city_id)->first(); return $locale?->name; } if ($this->location_division_id) { $locale = \App\Models\Locations\DivisionLocale::where('division_id', $this->location_division_id)->first(); return $locale?->name; } if ($this->location_country_id) { $locale = \App\Models\Locations\CountryLocale::where('country_id', $this->location_country_id)->first(); return $locale?->name; } return null; } /** * 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}"); $batchSize = timebank_config('mailing.batch_size', 10); $recipientsByLocale = $this->getRecipientsGroupedByAvailableLocales(); $totalJobsDispatched = 0; foreach ($recipientsByLocale as $locale => $recipients) { $contentBlocks = $this->getContentBlocksForLocale($locale); if (!empty($contentBlocks) && !empty($recipients)) { $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}"); foreach ($recipientBatches as $batchIndex => $batch) { Log::info("MAILING: Dispatching batch " . ($batchIndex + 1) . "/{$batchCount} for Mailing ID: {$this->id}, Locale: {$locale}, Recipients in batch: " . $batch->count()); \App\Jobs\SendBulkMailJob::dispatch($this, $locale, [], $batch) ->onQueue('mailing'); $totalJobsDispatched++; } } } $totalRecipients = collect($recipientsByLocale)->sum(fn($r) => count($r)); $this->update(['recipients_count' => $totalRecipients]); $suppressedCount = MailingBounce::where('is_suppressed', true)->count(); Log::info("MAILING: Completed dispatch for Mailing ID: {$this->id} - Jobs dispatched: {$totalJobsDispatched}, Locales: " . count($recipientsByLocale) . ", Recipients: {$totalRecipients}, Suppressed emails: {$suppressedCount}"); if (empty($recipientsByLocale) || $totalRecipients === 0) { Log::warning("MAILING: No jobs dispatched for Mailing ID: {$this->id} - marking as sent immediately"); $this->update([ 'status' => 'sent', 'sent_at' => now() ]); } } }