1210 lines
42 KiB
PHP
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;
|
|
}
|
|
} |