Initial commit
This commit is contained in:
589
app/Models/Mailing.php
Normal file
589
app/Models/Mailing.php
Normal file
@@ -0,0 +1,589 @@
|
||||
<?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()
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user