Initial commit

This commit is contained in:
Ronald Huynen
2026-03-23 21:37:59 +01:00
commit 2547717edb
2193 changed files with 972171 additions and 0 deletions

589
app/Models/Mailing.php Normal file
View 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()
]);
}
}
}