Files
timebank-cc-public/app/Http/Livewire/Mailings/Manage.php
Ronald Huynen 2547717edb Initial commit
2026-03-23 21:37:59 +01:00

1210 lines
42 KiB
PHP

<?php
namespace App\Http\Livewire\Mailings;
use App\Http\Livewire\Traits\RequiresAdminAuthorization;
use App\Models\Mailing;
use App\Models\Post;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Livewire\Component;
use Livewire\WithPagination;
use WireUi\Traits\WireUiActions;
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
class Manage extends Component
{
use WithPagination, WireUiActions, RequiresAdminAuthorization;
// Search and filtering
public $search = '';
public $typeFilter = '';
public $statusFilter = '';
public $sortField = 'created_at';
public $sortDirection = 'desc';
// Bulk selection
public $bulkSelected = [];
public $bulkDisabled = true;
// Modal controls
public $showCreateModal = false;
public $showEditModal = false;
public $showPreviewModal = false;
public $showTestMailModal = false;
public $showTestEmailSelectionModal = false;
public $showBulkDeleteModal = false;
public $showSendConfirmModal = false;
public $editingMailing = null;
public $testMailMessage = '';
public $mailingToSend = null;
public $mailingForTest = null;
// Test email selection properties
public $sendToAuthUser = false;
public $sendToActiveProfile = false;
public $customTestEmail = '';
public $availableTestEmails = [];
// Form data
public $title = '';
public $type = '';
public $subjects = []; // Multilingual subjects array
public $selectedPosts = [];
public $scheduledAt = null;
public $availableLocales = [];
// Profile type filtering
public $filterByProfileType = false;
public $selectedProfileTypes = [];
// Location filtering
public $filterByLocation = false;
public $selectedCountryIds = [];
public $selectedDivisionIds = [];
public $selectedCityIds = [];
public $selectedDistrictIds = [];
public $estimatedRecipientCount = 0;
// Post selection
public $showPostSelector = false;
public $postSearch = '';
public $selectedPostIds = [];
protected $rules = [
'title' => 'required|string|max:255',
'type' => 'required|in:local_newsletter,general_newsletter,system_message',
'subjects' => 'required|array|min:1',
'subjects.*' => 'required|string|max:255',
'selectedPosts' => 'array',
'scheduledAt' => 'nullable|date|after:now',
'filterByProfileType' => 'boolean',
'selectedProfileTypes' => 'array',
'selectedProfileTypes.*' => 'string|in:User,Organization,Bank,Admin',
'filterByLocation' => 'boolean',
'selectedCountryIds' => 'array',
'selectedCountryIds.*' => 'integer|exists:countries,id',
'selectedDivisionIds' => 'array',
'selectedDivisionIds.*' => 'integer|exists:divisions,id',
'selectedCityIds' => 'array',
'selectedCityIds.*' => 'integer|exists:cities,id',
'selectedDistrictIds' => 'array',
'selectedDistrictIds.*' => 'integer|exists:districts,id',
'customTestEmail' => 'nullable|email',
];
public function mount()
{
// Admin Authorization - Prevent IDOR attacks and cross-guard access
$activeProfileType = session('activeProfileType');
$activeProfileId = session('activeProfileId');
if (!$activeProfileType || !$activeProfileId) {
abort(403, __('No active profile selected'));
}
$profile = $activeProfileType::find($activeProfileId);
if (!$profile) {
abort(403, __('Active profile not found'));
}
// Validate profile ownership using ProfileAuthorizationHelper (prevents cross-guard attacks)
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
// Verify admin or central bank permissions
if ($profile instanceof \App\Models\Admin) {
// Admin access OK
} elseif ($profile instanceof \App\Models\Bank) {
// Only central bank (level 0) can access mailing management
if ($profile->level !== 0) {
abort(403, __('Central bank access required for mailing management'));
}
} else {
abort(403, __('Admin or central bank access required'));
}
// Log admin access for security monitoring
\Log::info('Mailings management access', [
'component' => 'Mailings\\Manage',
'profile_id' => $profile->id,
'profile_type' => get_class($profile),
'authenticated_guard' => \Auth::getDefaultDriver(),
'ip_address' => request()->ip(),
]);
// Initialize estimated recipient count to 0 since no type is selected initially
$this->estimatedRecipientCount = 0;
}
public function updatedPage()
{
$this->dispatch('scroll-to-top');
}
public function render()
{
$mailingsQuery = Mailing::with(['updatedByUser:id,name,full_name,profile_photo_path'])
->when($this->search, function ($query) {
$query->where(function ($q) {
$q->where('title', 'like', '%' . $this->search . '%')
->orWhere('subject', 'like', '%' . $this->search . '%');
});
})
->when($this->typeFilter, function ($query) {
$query->where('type', $this->typeFilter);
})
->when($this->statusFilter, function ($query) {
$query->where('status', $this->statusFilter);
})
->orderBy($this->sortField, $this->sortDirection);
$mailings = $mailingsQuery->paginate(15);
return view('livewire.mailings.manage', [
'mailings' => $mailings,
'availablePosts' => $this->getAvailablePosts(),
]);
}
public function updatedSearch()
{
$this->resetPage();
}
public function updatedTypeFilter()
{
$this->resetPage();
}
public function updatedStatusFilter()
{
$this->resetPage();
}
public function sortBy($field)
{
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortDirection = 'asc';
}
$this->sortField = $field;
}
public function openCreateModal()
{
$this->resetForm();
$this->updateAvailableLocales(); // Initialize with base language
$this->showCreateModal = true;
}
public function openEditModal($mailingId)
{
$this->editingMailing = Mailing::findOrFail($mailingId);
// Can only edit drafts
if (!$this->editingMailing->canBeScheduled()) {
$this->notification([
'title' => __('Error'),
'description' => __('Only draft mailings can be edited.'),
'icon' => 'error'
]);
return;
}
$this->title = $this->editingMailing->title;
$this->type = $this->editingMailing->type;
$this->subjects = $this->editingMailing->getAllSubjects();
// Load content blocks and enrich with post titles
$this->selectedPosts = $this->loadContentBlocksWithTitles($this->editingMailing->content_blocks ?? []);
// Load profile type filter settings
$this->filterByProfileType = $this->editingMailing->filter_by_profile_type ?? false;
$this->selectedProfileTypes = $this->editingMailing->selected_profile_types ?? [];
// Load location filter settings
$this->filterByLocation = $this->editingMailing->filter_by_location ?? false;
// Convert single IDs to arrays for multiple selection support
$this->selectedCountryIds = $this->editingMailing->location_country_id ? [$this->editingMailing->location_country_id] : [];
$this->selectedDivisionIds = $this->editingMailing->location_division_id ? [$this->editingMailing->location_division_id] : [];
$this->selectedCityIds = $this->editingMailing->location_city_id ? [$this->editingMailing->location_city_id] : [];
$this->selectedDistrictIds = $this->editingMailing->location_district_id ? [$this->editingMailing->location_district_id] : [];
// Load available locales from posts
$this->updateAvailableLocales();
$this->scheduledAt = $this->editingMailing->scheduled_at?->format('Y-m-d\TH:i');
// Send location data to LocationFilter component
if ($this->filterByLocation) {
$this->dispatch('loadLocationFilterData', [
'countries' => $this->selectedCountryIds,
'divisions' => $this->selectedDivisionIds,
'cities' => $this->selectedCityIds,
'districts' => $this->selectedDistrictIds,
]);
}
$this->showEditModal = true;
}
public function closeModals()
{
$this->showCreateModal = false;
$this->showEditModal = false;
$this->showPreviewModal = false;
$this->showTestMailModal = false;
$this->showTestEmailSelectionModal = false;
$this->showBulkDeleteModal = false;
$this->showSendConfirmModal = false;
$this->showPostSelector = false;
$this->resetForm();
}
public function resetForm()
{
$this->title = '';
$this->type = '';
$this->subjects = [];
$this->selectedPosts = [];
$this->scheduledAt = null;
$this->editingMailing = null;
$this->selectedPostIds = [];
$this->availableLocales = [];
$this->filterByProfileType = false;
$this->selectedProfileTypes = [];
$this->filterByLocation = false;
$this->selectedCountryIds = [];
$this->selectedDivisionIds = [];
$this->selectedCityIds = [];
$this->selectedDistrictIds = [];
$this->estimatedRecipientCount = 0;
// Reset test email selection properties
$this->sendToAuthUser = false;
$this->sendToActiveProfile = false;
$this->customTestEmail = '';
$this->mailingForTest = null;
$this->availableTestEmails = [];
$this->resetValidation();
}
public function clearProfileTypes()
{
$this->selectedProfileTypes = [];
$this->updateEstimatedRecipients();
}
public function updateEstimatedRecipients()
{
// If no type is selected, set count to 0
if (empty($this->type)) {
$this->estimatedRecipientCount = 0;
return;
}
// Create a temporary mailing instance with current form data
$tempMailing = new \App\Models\Mailing();
$tempMailing->type = $this->type;
$tempMailing->filter_by_profile_type = $this->filterByProfileType;
$tempMailing->selected_profile_types = $this->selectedProfileTypes;
$tempMailing->filter_by_location = $this->filterByLocation;
$tempMailing->location_country_id = !empty($this->selectedCountryIds) ? $this->selectedCountryIds[0] : null;
$tempMailing->location_division_id = !empty($this->selectedDivisionIds) ? $this->selectedDivisionIds[0] : null;
$tempMailing->location_city_id = !empty($this->selectedCityIds) ? $this->selectedCityIds[0] : null;
$tempMailing->location_district_id = !empty($this->selectedDistrictIds) ? $this->selectedDistrictIds[0] : null;
$this->estimatedRecipientCount = $tempMailing->getRecipientsQuery()->count();
}
// Reactive property updates
public function updatedFilterByProfileType()
{
$this->updateEstimatedRecipients();
}
public function updatedSelectedProfileTypes()
{
$this->updateEstimatedRecipients();
}
// Location filtering event listeners
#[On('locationFilterUpdated')]
public function handleLocationFilterUpdate($locationData)
{
// Store the data without triggering re-renders
$this->selectedCountryIds = $locationData['countries'] ?? [];
$this->selectedDivisionIds = $locationData['divisions'] ?? [];
$this->selectedCityIds = $locationData['cities'] ?? [];
$this->selectedDistrictIds = $locationData['districts'] ?? [];
$this->updateRecipientCount();
// Don't emit any events that would cause the LocationFilter to re-render
}
public function updatedFilterByLocation()
{
if (!$this->filterByLocation) {
$this->selectedCountryIds = [];
$this->selectedDivisionIds = [];
$this->selectedCityIds = [];
$this->selectedDistrictIds = [];
// Reset the LocationFilter component
$this->dispatch('resetLocationFilter');
}
$this->updateRecipientCount();
}
public function updatedType()
{
$this->updateEstimatedRecipients();
}
public function updateRecipientCount()
{
$tempMailing = new Mailing();
$tempMailing->type = $this->type;
// Apply location filtering
$query = $this->getFilteredRecipientsQuery($tempMailing);
$this->estimatedRecipientCount = $query->count();
}
private function getFilteredRecipientsQuery($mailing)
{
$baseQuery = $mailing->getRecipientsQuery();
if (!$this->filterByLocation) {
return $baseQuery;
}
// Apply location filtering based on selected locations
return $baseQuery->whereHas('locations', function ($query) {
if (!empty($this->selectedDistrictIds)) {
$query->whereIn('district_id', $this->selectedDistrictIds);
} elseif (!empty($this->selectedCityIds)) {
$query->whereIn('city_id', $this->selectedCityIds);
} elseif (!empty($this->selectedDivisionIds)) {
$query->whereIn('division_id', $this->selectedDivisionIds);
} elseif (!empty($this->selectedCountryIds)) {
$query->whereIn('country_id', $this->selectedCountryIds);
}
});
}
public function updateAvailableLocales()
{
if (!empty($this->selectedPosts)) {
// Create a temporary mailing object to get available locales
$tempMailing = new \App\Models\Mailing();
$tempMailing->content_blocks = $this->prepareContentBlocks();
$this->availableLocales = $tempMailing->getAvailablePostLocales();
} else {
// If no posts selected, show base language
$this->availableLocales = [timebank_config('base_language', 'en')];
}
// Ensure subjects array has all available locales
foreach ($this->availableLocales as $locale) {
if (!isset($this->subjects[$locale])) {
$this->subjects[$locale] = '';
}
}
}
/**
* Get current recipient counts by locale based on form state
*/
public function getCurrentRecipientCountsByLocale()
{
// If no type is selected, return empty array
if (empty($this->type)) {
return [];
}
// Create a temporary mailing object with current form state
$tempMailing = new \App\Models\Mailing();
$tempMailing->type = $this->type;
$tempMailing->filter_by_profile_type = $this->filterByProfileType;
$tempMailing->selected_profile_types = $this->selectedProfileTypes;
$tempMailing->filter_by_location = $this->filterByLocation;
$tempMailing->content_blocks = $this->prepareContentBlocks();
// Apply location filtering if enabled
if ($this->filterByLocation) {
// Use the first selected ID for compatibility with existing model logic
$tempMailing->location_country_id = !empty($this->selectedCountryIds) ? $this->selectedCountryIds[0] : null;
$tempMailing->location_division_id = !empty($this->selectedDivisionIds) ? $this->selectedDivisionIds[0] : null;
$tempMailing->location_city_id = !empty($this->selectedCityIds) ? $this->selectedCityIds[0] : null;
$tempMailing->location_district_id = !empty($this->selectedDistrictIds) ? $this->selectedDistrictIds[0] : null;
}
return $tempMailing->getRecipientCountsByLocale();
}
public function saveMailing()
{
// CRITICAL: Authorize admin access for saving mailing
$this->authorizeAdminAccess();
// Conditional validation: selectedPosts required if scheduling
$rules = $this->rules;
if ($this->scheduledAt) {
$rules['selectedPosts'] = 'required|array|min:1';
}
$this->validate($rules);
$data = [
'title' => $this->title,
'type' => $this->type,
'subject' => $this->subjects,
'content_blocks' => $this->prepareContentBlocks(),
'scheduled_at' => $this->scheduledAt ? \Carbon\Carbon::parse($this->scheduledAt) : null,
'filter_by_profile_type' => $this->filterByProfileType,
'selected_profile_types' => $this->selectedProfileTypes,
'filter_by_location' => $this->filterByLocation,
// For now, save the first selected ID to maintain compatibility with existing DB schema
'location_country_id' => !empty($this->selectedCountryIds) ? $this->selectedCountryIds[0] : null,
'location_division_id' => !empty($this->selectedDivisionIds) ? $this->selectedDivisionIds[0] : null,
'location_city_id' => !empty($this->selectedCityIds) ? $this->selectedCityIds[0] : null,
'location_district_id' => !empty($this->selectedDistrictIds) ? $this->selectedDistrictIds[0] : null,
'updated_by_user_id' => Auth::guard('web')->id(),
];
try {
if ($this->editingMailing) {
// Update existing mailing
$data['status'] = $data['scheduled_at'] ? 'scheduled' : 'draft';
$this->editingMailing->update($data);
$this->editingMailing->recipients_count = $this->editingMailing->getRecipientsQuery()->count();
$this->editingMailing->save();
$this->notification([
'title' => __('Success'),
'description' => __('Mailing updated successfully.'),
'icon' => 'success'
]);
} else {
// Create new mailing
$data['status'] = $data['scheduled_at'] ? 'scheduled' : 'draft';
$mailing = Mailing::create($data);
$mailing->recipients_count = $mailing->getRecipientsQuery()->count();
$mailing->save();
$this->notification([
'title' => __('Success'),
'description' => __('Mailing created successfully.'),
'icon' => 'success'
]);
}
$this->closeModals();
$this->dispatch('mailingCreated');
} catch (\Exception $e) {
$this->notification([
'title' => __('Error'),
'description' => __('Failed to save mailing: :error', ['error' => $e->getMessage()]),
'icon' => 'error'
]);
}
}
public function deleteMailing($mailingId)
{
// CRITICAL: Authorize admin access for deleting mailing
$this->authorizeAdminAccess();
$mailing = Mailing::findOrFail($mailingId);
if (!in_array($mailing->status, ['draft', 'scheduled'])) {
$this->notification([
'title' => __('Error'),
'description' => __('Cannot delete sent or sending mailings.'),
'icon' => 'error'
]);
return;
}
$mailing->delete();
$this->notification([
'title' => __('Success'),
'description' => __('Mailing deleted successfully.'),
'icon' => 'success'
]);
$this->dispatch('mailingDeleted');
}
public function openSendConfirmModal($mailingId)
{
$this->mailingToSend = Mailing::findOrFail($mailingId);
if (!$this->mailingToSend->canBeSent()) {
$this->notification([
'title' => __('Error'),
'description' => __('Mailing cannot be sent in its current status.'),
'icon' => 'error'
]);
return;
}
// Note: getEffectiveRecipientsCount() now correctly applies location filtering
$this->showSendConfirmModal = true;
}
public function sendMailing()
{
// CRITICAL: Authorize admin access for sending mailing
$this->authorizeAdminAccess();
if (!$this->mailingToSend) {
return;
}
$this->mailingToSend->update([
'status' => 'sending',
'updated_by_user_id' => Auth::guard('web')->id(),
]);
// Dispatch locale-specific email jobs
$this->mailingToSend->dispatchLocaleSpecificJobs();
// TODO: Replace with actual job dispatch in Phase 6
// This will dispatch separate jobs for each locale with filtered content
$this->showSendConfirmModal = false;
$this->mailingToSend = null;
$this->notification([
'title' => __('Success'),
'description' => __('Mailing is being sent. This process may take several minutes.'),
'icon' => 'success'
]);
$this->dispatch('mailingUpdated');
}
public function updatedBulkSelected()
{
$this->bulkDisabled = count($this->bulkSelected) === 0;
}
public function openBulkDeleteModal()
{
if (count($this->bulkSelected) === 0) {
$this->notification([
'title' => __('Error'),
'description' => __('Please select mailings to delete.'),
'icon' => 'error'
]);
return;
}
$this->showBulkDeleteModal = true;
}
public function bulkDeleteMailings()
{
// CRITICAL: Authorize admin access for bulk deleting mailings
$this->authorizeAdminAccess();
$mailings = Mailing::whereIn('id', $this->bulkSelected)->get();
$deleted = 0;
$errors = 0;
foreach ($mailings as $mailing) {
if (in_array($mailing->status, ['draft', 'scheduled'])) {
$mailing->delete();
$deleted++;
} else {
$errors++;
}
}
$this->showBulkDeleteModal = false;
$this->bulkSelected = [];
$this->bulkDisabled = true;
if ($deleted > 0) {
$this->notification([
'title' => __('Success'),
'description' => ($errors > 0 ? __(':deleted mailing(s) deleted successfully. :errors mailing(s) could not be deleted due to status restrictions.', ['deleted' => $deleted, 'errors' => $errors]) : __(':deleted mailing(s) deleted successfully.', ['deleted' => $deleted])),
'icon' => 'success'
]);
} else {
$this->notification([
'title' => __('Error'),
'description' => __('No mailings could be deleted. Only draft and scheduled mailings can be deleted.'),
'icon' => 'error'
]);
}
$this->dispatch('mailingDeleted');
}
public function sendTestMail($mailingId)
{
// CRITICAL: Authorize admin access for sending test mail
$this->authorizeAdminAccess();
$mailing = Mailing::findOrFail($mailingId);
// Test mail is allowed for draft, scheduled, and even sending mailings
if (!in_array($mailing->status, ['draft', 'scheduled', 'sending'])) {
$this->notification([
'title' => __('Error'),
'description' => __('Test mail can only be sent for draft, scheduled, or sending mailings.'),
'icon' => 'error'
]);
return;
}
// Check if mailing has content
$availableLocales = collect(array_keys(config('laravellocalization.supportedLocales', [])))
->filter(function ($locale) use ($mailing) {
$contentBlocks = $mailing->getContentBlocksForLocale($locale);
return !empty($contentBlocks);
});
if ($availableLocales->isEmpty()) {
$this->notification([
'title' => __('Error'),
'description' => __('No content available in any language for this mailing.'),
'icon' => 'error'
]);
return;
}
// Prepare available test emails
$this->prepareTestEmailOptions();
// Store mailing for test
$this->mailingForTest = $mailing;
// Show email selection modal
$this->showTestEmailSelectionModal = true;
}
public function cancelMailing($mailingId)
{
$mailing = Mailing::findOrFail($mailingId);
if (!$mailing->canBeCancelled()) {
$this->notification([
'title' => __('Error'),
'description' => __('This mailing cannot be unscheduled.'),
'icon' => 'error'
]);
return;
}
// Remove the scheduled datetime and change status back to draft
$mailing->update([
'scheduled_at' => null,
'status' => 'draft'
]);
$this->notification([
'title' => __('Success'),
'description' => __('Mailing has been unscheduled successfully and can now be edited.'),
'icon' => 'success'
]);
$this->dispatch('mailingUpdated');
}
public function openPostSelector()
{
$this->showPostSelector = true;
}
public function togglePostSelection($postId)
{
if (in_array($postId, $this->selectedPostIds)) {
$this->selectedPostIds = array_filter($this->selectedPostIds, fn($id) => $id != $postId);
} else {
$this->selectedPostIds[] = $postId;
}
}
public function addSelectedPosts()
{
$posts = Post::whereIn('id', $this->selectedPostIds)
->with('translations')
->get();
foreach ($posts as $index => $post) {
if (!collect($this->selectedPosts)->firstWhere('post_id', $post->id)) {
$this->selectedPosts[] = [
'post_id' => $post->id,
'order' => count($this->selectedPosts) + 1,
'title' => $post->translations->first()->title ?? __('Untitled')
];
}
}
$this->selectedPostIds = [];
$this->showPostSelector = false;
$this->postSearch = '';
// Update available locales when posts are added
$this->updateAvailableLocales();
}
public function removePost($index)
{
unset($this->selectedPosts[$index]);
$this->selectedPosts = array_values($this->selectedPosts);
// Reorder posts
foreach ($this->selectedPosts as $i => $post) {
$this->selectedPosts[$i]['order'] = $i + 1;
}
// Update available locales when posts are removed
$this->updateAvailableLocales();
}
public function movePostUp($index)
{
if ($index > 0) {
$temp = $this->selectedPosts[$index];
$this->selectedPosts[$index] = $this->selectedPosts[$index - 1];
$this->selectedPosts[$index - 1] = $temp;
// Update order numbers
$this->selectedPosts[$index]['order'] = $index + 1;
$this->selectedPosts[$index - 1]['order'] = $index;
$this->updateAvailableLocales();
}
}
public function movePostDown($index)
{
if ($index < count($this->selectedPosts) - 1) {
$temp = $this->selectedPosts[$index];
$this->selectedPosts[$index] = $this->selectedPosts[$index + 1];
$this->selectedPosts[$index + 1] = $temp;
// Update order numbers
$this->selectedPosts[$index]['order'] = $index + 1;
$this->selectedPosts[$index + 1]['order'] = $index + 2;
$this->updateAvailableLocales();
}
}
private function prepareContentBlocks()
{
return collect($this->selectedPosts)->map(function ($post, $index) {
return [
'post_id' => $post['post_id'],
'order' => $index + 1
];
})->toArray();
}
private function getAvailablePosts()
{
if (!$this->showPostSelector) {
return collect();
}
return Post::with(['translations', 'category.translations'])
->whereHas('translations', function ($query) {
$query->where('status', 1) // Status 1 = published
->where('from', '<=', now())
->where(function ($q) {
$q->whereNull('till')
->orWhere('till', '>=', now());
});
if ($this->postSearch) {
$query->where('title', 'like', '%' . $this->postSearch . '%');
}
})
->orderBy('updated_at', 'desc')
->limit(20)
->get();
}
#[On('mailingCreated')]
#[On('mailingUpdated')]
#[On('mailingDeleted')]
public function refreshMailings()
{
// This method will be called by listeners to refresh the mailings list
// In Livewire 3, we don't need to call render() explicitly
}
/**
* Load content blocks and enrich with post titles for editing
*/
private function loadContentBlocksWithTitles($contentBlocks)
{
if (empty($contentBlocks)) {
return [];
}
$postIds = collect($contentBlocks)->pluck('post_id');
$posts = Post::whereIn('id', $postIds)
->with('translations')
->get()
->keyBy('id');
return collect($contentBlocks)->map(function ($block) use ($posts) {
$post = $posts->get($block['post_id']);
return [
'post_id' => $block['post_id'],
'order' => $block['order'],
'title' => $post && $post->translations->first()
? $post->translations->first()->title
: __('Post not found (ID: :id)', ['id' => $block['post_id']])
];
})->toArray();
}
/**
* Get published translations with their language flags for a post
*/
public function getPublishedTranslationsWithFlags($post)
{
$now = now();
return $post->translations->filter(function ($translation) use ($now) {
return $translation->status == 1
&& $translation->from <= $now
&& ($translation->till === null || $translation->till >= $now);
})->map(function ($translation) {
$language = \App\Models\Language::where('lang_code', $translation->locale)->first();
return [
'locale' => $translation->locale,
'flag' => $language ? $language->flag : '🏳️'
];
});
}
/**
* Prepare test email options for the modal
*/
public function prepareTestEmailOptions()
{
$this->availableTestEmails = [];
// Option 1: Authenticated user email (underlying web user)
$webUser = Auth::guard('web')->user();
if ($webUser && $webUser->email) {
$this->availableTestEmails['auth_user'] = [
'email' => $webUser->email,
'label' => __('Your user profile: :email', ['email' => $webUser->email]),
'available' => true
];
}
// Option 2: Active profile email (if different from auth user)
$activeProfile = getActiveProfile();
if ($activeProfile && $activeProfile->email &&
(!isset($this->availableTestEmails['auth_user']) || $activeProfile->email !== $this->availableTestEmails['auth_user']['email'])) {
$this->availableTestEmails['active_profile'] = [
'email' => $activeProfile->email,
'label' => __('Your current active profile: :email', ['email' => $activeProfile->email]),
'available' => true
];
}
// Reset checkboxes
$this->sendToAuthUser = false;
$this->sendToActiveProfile = false;
$this->customTestEmail = '';
}
/**
* Send test mailing mailing to selected email addresses
*/
public function sendTestMailToSelected()
{
// CRITICAL: Authorize admin access for sending test mail to selected recipients
$this->authorizeAdminAccess();
if (!$this->mailingForTest) {
$this->notification([
'title' => __('Error'),
'description' => __('No mailing selected for testing.'),
'icon' => 'error'
]);
return;
}
// Collect selected emails
$testEmails = [];
if ($this->sendToAuthUser && isset($this->availableTestEmails['auth_user'])) {
$testEmails[] = $this->availableTestEmails['auth_user']['email'];
}
if ($this->sendToActiveProfile && isset($this->availableTestEmails['active_profile'])) {
$testEmails[] = $this->availableTestEmails['active_profile']['email'];
}
if (!empty($this->customTestEmail)) {
// Validate custom email
$this->validateOnly('customTestEmail');
$testEmails[] = $this->customTestEmail;
}
// Check if at least one email is selected
if (empty($testEmails)) {
$this->notification([
'title' => __('Error'),
'description' => __('Please select at least one email address to send the test to.'),
'icon' => 'error'
]);
return;
}
// Remove duplicates
$testEmails = array_unique($testEmails);
// Get all available locales that have content
$availableLocales = collect(array_keys(config('laravellocalization.supportedLocales', [])))
->filter(function ($locale) {
$contentBlocks = $this->mailingForTest->getContentBlocksForLocale($locale);
return !empty($contentBlocks);
});
if ($availableLocales->isEmpty()) {
$this->notification([
'title' => __('Error'),
'description' => __('No content available in any language for this mailing.'),
'icon' => 'error'
]);
return;
}
try {
// Save current locale to restore it after sending test emails
$originalLocale = \App::getLocale();
$totalEmailsSent = 0;
// Send test mailing mail to each selected email in each available locale
foreach ($testEmails as $email) {
foreach ($availableLocales as $locale) {
$testMail = new \App\Mail\TestNewsletterMail($this->mailingForTest, $locale);
\Mail::to($email)->send($testMail);
$totalEmailsSent++;
// Add delay to avoid rate limiting (except for last email)
if ($email !== end($testEmails) || $locale !== $availableLocales->last()) {
sleep(2); // 2 second delay between emails
}
}
}
// Restore original locale before displaying success message
\App::setLocale($originalLocale);
$localeCount = $availableLocales->count();
$recipientCount = count($testEmails);
// Close selection modal and show success modal
$this->showTestEmailSelectionModal = false;
// Show success modal with details
$this->testMailMessage = __('Test emails sent successfully!') . "\n\n" .
__('Recipients: :recipients', ['recipients' => implode(', ', $testEmails)]) . "\n\n" .
__('Languages: :languages', ['languages' => $availableLocales->implode(', ')]) . "\n\n" .
__('Total emails sent: :count', ['count' => $totalEmailsSent]);
$this->showTestMailModal = true;
} catch (\Exception $e) {
// Restore original locale before displaying error message
if (isset($originalLocale)) {
\App::setLocale($originalLocale);
}
\Log::error('sendTestMailToSelected exception: ' . $e->getMessage());
\Log::error('Exception trace: ' . $e->getTraceAsString());
// Close selection modal and show error modal
$this->showTestEmailSelectionModal = false;
$this->testMailMessage = __('Test Email Error') . "\n\n" .
__('Recipients: :recipients', ['recipients' => implode(', ', $testEmails)]) . "\n\n" .
__('Error: :error', ['error' => $e->getMessage()]);
$this->showTestMailModal = true;
}
}
/**
* Cancel test email selection and close modal
*/
public function cancelTestEmailSelection()
{
$this->showTestEmailSelectionModal = false;
$this->mailingForTest = null;
$this->sendToAuthUser = false;
$this->sendToActiveProfile = false;
$this->customTestEmail = '';
$this->availableTestEmails = [];
$this->resetValidation(['customTestEmail']);
}
/**
* Generate HTML preview of the mailing
*/
public function getMailingPreviewHtml()
{
if (!$this->mailingForTest) {
return '<p class="text-sm text-gray-500">' . __('No mailing selected') . '</p>';
}
// Get the base language for preview
$locale = timebank_config('base_language', 'en');
// Generate content blocks for preview
$contentBlocks = [];
foreach ($this->mailingForTest->getContentBlocksForLocale($locale) as $block) {
$post = \App\Models\Post::with(['translations', 'category'])->find($block['post_id']);
if (!$post) {
continue;
}
// Get translation for the locale
$translation = $this->mailingForTest->getPostTranslationForLocale($post->id, $locale);
if (!$translation) {
continue;
}
// Determine post type
$postType = $this->determinePostType($post);
// Prepare post data
$postData = $this->preparePostDataForPreview($post, $translation, $locale);
$contentBlocks[] = [
'type' => $postType,
'data' => $postData,
'template' => timebank_config("mailing.templates.{$postType}_block")
];
}
// Render the email view
return view('emails.newsletter.wrapper', [
'subject' => $this->mailingForTest->getSubjectForLocale($locale),
'mailingTitle' => $this->mailingForTest->title,
'locale' => $locale,
'contentBlocks' => $contentBlocks,
'unsubscribeUrl' => '#preview-unsubscribe',
'isTestMail' => false,
])->render();
}
/**
* Determine post type for preview
*/
private function determinePostType($post)
{
// Check for ImagePost category type first
if ($post->category && $post->category->type && str_starts_with($post->category->type, 'App\\Models\\ImagePost')) {
return 'image';
}
if ($post->category && $post->category->id) {
$categoryMappings = [
4 => 'event', // The Hague events
5 => 'event', // South-Holland events
6 => 'event', // The Netherlands events
7 => 'news', // The Hague news
8 => 'news', // General news
113 => 'article', // Article
];
return $categoryMappings[$post->category->id] ?? 'news';
}
if ($post->meeting || (isset($post->from) && $post->from)) {
return 'event';
}
return 'news';
}
/**
* Prepare post data for preview
*/
private function preparePostDataForPreview($post, $translation, $locale)
{
// Generate fully localized URL with translated route path for the preview locale
$url = LaravelLocalization::getURLFromRouteNameTranslated(
$locale,
'routes.post.show_by_slug',
['slug' => $translation->slug]
);
$data = [
'title' => $translation->title,
'excerpt' => $translation->excerpt,
'content' => $translation->content,
'url' => $url,
'date' => $post->updated_at->locale($locale)->translatedFormat('M j, Y'),
'author' => $post->author ? $post->author->name : null,
];
// Add category information
if ($post->category) {
$categoryTranslation = $post->category->translations()->where('locale', $locale)->first();
$data['category'] = $categoryTranslation ? $categoryTranslation->name : $post->category->translations()->first()->name;
}
// Add location prefix
if ($post->category && $post->category->categoryable && $post->category->categoryable->translations) {
$locationTranslation = $post->category->categoryable->translations->where('locale', $locale)->first();
if ($locationTranslation && $locationTranslation->name) {
$data['location_prefix'] = strtoupper($locationTranslation->name);
}
}
// Add event-specific data
if ($post->meeting) {
$data['venue'] = $post->meeting->venue;
$data['address'] = $post->meeting->address;
}
// Add event date/time
if ($translation->from) {
$eventDate = \Carbon\Carbon::parse($translation->from);
$data['event_date'] = $eventDate->locale($locale)->translatedFormat('F j');
$data['event_time'] = $eventDate->locale($locale)->translatedFormat('H:i');
}
// Add image - use email conversion (resized without cropping)
if ($post->getFirstMediaUrl('posts')) {
$data['image'] = $post->getFirstMediaUrl('posts', 'email');
// Add media caption and owner for image posts
$media = $post->getFirstMedia('posts');
if ($media) {
$captionKey = 'caption-' . $locale;
$data['media_caption'] = $media->getCustomProperty($captionKey, '');
$data['media_owner'] = $media->getCustomProperty('owner', '');
}
}
return $data;
}
}