589 lines
19 KiB
PHP
589 lines
19 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use App\Models\Admin;
|
|
use App\Models\Bank;
|
|
use App\Models\MessageSetting;
|
|
use App\Models\Organization;
|
|
use App\Models\Post;
|
|
use App\Models\User;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
use Illuminate\Support\Facades\App;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class Mailing extends Model
|
|
{
|
|
use HasFactory, SoftDeletes;
|
|
|
|
protected $fillable = [
|
|
'title', 'type', 'subject', 'content_blocks', 'scheduled_at', 'sent_at',
|
|
'status', 'recipients_count', 'sent_count', 'failed_count',
|
|
'updated_by_user_id', 'filter_by_location', 'location_country_id',
|
|
'location_division_id', 'location_city_id', 'location_district_id',
|
|
'filter_by_profile_type', 'selected_profile_types'
|
|
];
|
|
|
|
protected $casts = [
|
|
'content_blocks' => '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()
|
|
]);
|
|
}
|
|
}
|
|
} |