Files
timebank-cc-public/app/Http/Livewire/Mailings/Manage.php
T

1609 lines
60 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\Locked;
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 $showReportModal = false;
#[Locked] public $mailingForReport = null;
#[Locked] public $editingMailing = null;
public $testMailMessage = '';
#[Locked] public $mailingToSend = null;
#[Locked] public $mailingToStop = null;
public $showStopConfirmModal = false;
#[Locked] 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 = [];
public $requiredLocales = []; // Locales that have both content AND recipients — subjects are required for these
// Profile type filtering
public $filterByProfileType = false;
public $selectedProfileTypes = [];
// Contact filtering
public $filterByContacts = false;
public $selectedContactProfiles = []; // full profile arrays for display
// Location filtering
public $filterByLocation = false;
public $selectedCountryIds = [];
public $selectedDivisionIds = [];
public $selectedCityIds = [];
public $selectedDistrictIds = [];
public $estimatedRecipientCount = 0;
// Fallback locale
public $fallbackLocale = '';
public $fallbackEligibleLocales = [];
// 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,general_newsletter_only',
'subjects' => 'required|array|min:1',
'selectedPosts' => 'array',
'scheduledAt' => 'nullable|date|after:now',
'filterByProfileType' => 'boolean',
'selectedProfileTypes' => 'array',
'selectedProfileTypes.*' => 'string|in:User,Organization,Bank,Admin',
'filterByContacts' => 'boolean',
'selectedContactProfiles' => 'array',
'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|string',
];
public function rules(): array
{
$supportedLocales = array_keys(config('laravellocalization.supportedLocales', []));
// Subjects are required for locales that have both content and recipients,
// and optional (but validated if filled) for other content locales.
$subjectRules = [];
foreach ($this->availableLocales as $locale) {
if (in_array($locale, $this->requiredLocales)) {
$subjectRules["subjects.{$locale}"] = 'required|string|max:255';
} else {
$subjectRules["subjects.{$locale}"] = 'nullable|string|max:255';
}
}
return array_merge($this->rules, $subjectRules, [
'fallbackLocale' => ['nullable', 'string', 'max:10', 'in:' . implode(',', array_merge([''], $supportedLocales))],
]);
}
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) {
// Require at least view or manage mailings permission
$webUser = \Auth::guard('web')->user();
if (!$webUser || !$webUser->can('access mailings')) {
abort(403, __('You do not have permission to access mailings.'));
}
} 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 . '%')
->orWhere('id', is_numeric($this->search) ? (int) $this->search : -1);
});
})
->when($this->typeFilter, function ($query) {
$query->where('type', $this->typeFilter);
})
->when($this->statusFilter, function ($query) {
$query->where('status', $this->statusFilter);
})
->orderBy(
in_array($this->sortField, ['title', 'status', 'type', 'created_at', 'updated_at', 'id']) ? $this->sortField : 'created_at',
$this->sortDirection === 'asc' ? 'asc' : 'desc'
);
$mailings = $mailingsQuery->paginate(15);
// Polls for only part of the members: the mailing reaches others too (security audit F8)
$limitedPollPostIds = \App\Models\Poll::whereIn('post_id', collect($this->selectedPosts)->pluck('post_id')->all())
->get()
->filter->hasLimitedAudience()
->pluck('post_id')
->all();
return view('livewire.mailings.manage', [
'mailings' => $mailings,
'availablePosts' => $this->getAvailablePosts(),
'limitedPollPostIds' => $limitedPollPostIds,
]);
}
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;
$this->dispatch('flatpickr-set-date', value: null);
}
public function openEditModal($mailingId)
{
$this->editingMailing = Mailing::findOrFail($mailingId);
// Can only edit drafts and scheduled mailings
if (!$this->editingMailing->canBeEdited()) {
$this->notification([
'title' => __('Error'),
'description' => __('Only draft and scheduled 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 contact filter settings
$this->filterByContacts = $this->editingMailing->filter_by_contacts ?? false;
$this->selectedContactProfiles = $this->resolveContactProfilesToDisplay($this->editingMailing->contact_profile_ids ?? []);
// Load fallback locale setting (updateAvailableLocales called below will set fallbackEligibleLocales)
$this->fallbackLocale = $this->editingMailing->fallback_locale ?? '';
// Load location filter settings
$this->filterByLocation = $this->editingMailing->filter_by_location ?? false;
// Load full multi-select arrays; fall back to singular column for old records
$this->selectedCountryIds = !empty($this->editingMailing->location_country_ids) ? $this->editingMailing->location_country_ids : ($this->editingMailing->location_country_id ? [$this->editingMailing->location_country_id] : []);
$this->selectedDivisionIds = !empty($this->editingMailing->location_division_ids) ? $this->editingMailing->location_division_ids : ($this->editingMailing->location_division_id ? [$this->editingMailing->location_division_id] : []);
$this->selectedCityIds = !empty($this->editingMailing->location_city_ids) ? $this->editingMailing->location_city_ids : ($this->editingMailing->location_city_id ? [$this->editingMailing->location_city_id] : []);
$this->selectedDistrictIds = !empty($this->editingMailing->location_district_ids) ? $this->editingMailing->location_district_ids : ($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');
$this->showEditModal = true;
// Hydrate the location filter child component with saved data via event
// (props on <livewire:> tags are ignored after initial mount when wire:ignore is used)
if ($this->filterByLocation) {
$this->dispatch('loadLocationFilterData', data: [
'countries' => $this->selectedCountryIds,
'divisions' => $this->selectedDivisionIds,
'cities' => $this->selectedCityIds,
'districts' => $this->selectedDistrictIds,
]);
}
// Hydrate the flatpickr with the saved scheduled date
$this->dispatch('flatpickr-set-date', value: $this->scheduledAt);
}
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->showStopConfirmModal = false;
$this->mailingToStop = null;
$this->showReportModal = false;
$this->mailingForReport = null;
$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->requiredLocales = [];
$this->filterByProfileType = false;
$this->selectedProfileTypes = [];
$this->filterByContacts = false;
$this->selectedContactProfiles = [];
$this->filterByLocation = false;
$this->selectedCountryIds = [];
$this->selectedDivisionIds = [];
$this->selectedCityIds = [];
$this->selectedDistrictIds = [];
$this->estimatedRecipientCount = 0;
$this->fallbackLocale = '';
$this->fallbackEligibleLocales = [];
// 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 updatedFilterByContacts()
{
if (!$this->filterByContacts) {
$this->selectedContactProfiles = [];
}
$this->updateEstimatedRecipients();
}
#[On('selectedProfiles')]
public function handleSelectedContactProfiles(array $profiles = [])
{
// Child dispatches a single profile object; parent appends and deduplicates
if (empty($profiles)) {
return;
}
$alreadySelected = collect($this->selectedContactProfiles)->contains(
fn($p) => $p['id'] === $profiles['id'] && $p['type'] === $profiles['type']
);
if (!$alreadySelected) {
$this->selectedContactProfiles[] = $profiles;
}
$this->updateEstimatedRecipients();
}
public function removeContactProfile($index)
{
unset($this->selectedContactProfiles[$index]);
$this->selectedContactProfiles = array_values($this->selectedContactProfiles);
$this->updateEstimatedRecipients();
}
/**
* Re-hydrate contact profile display data (name, photo, description) from stored {id, type} pairs.
*/
private function resolveContactProfilesToDisplay(array $contactProfileIds): array
{
if (empty($contactProfileIds)) {
return [];
}
$resolved = [];
foreach ($contactProfileIds as $entry) {
if (empty($entry['id']) || empty($entry['type'])) {
continue;
}
$modelClass = $entry['type'];
if (!class_exists($modelClass)) {
continue;
}
$profile = $modelClass::find($entry['id']);
if (!$profile) {
continue;
}
$profile->loadMissing(['locations.city.translations', 'locations.district.translations', 'locations.division.translations']);
$resolved[] = [
'id' => $profile->id,
'type' => $modelClass,
'name' => $profile->name,
'description' => $this->profileTypeLabel($modelClass),
'location' => $profile->getLocationFirstShort(),
'profile_photo_path' => url(\Storage::url($profile->profile_photo_path)),
];
}
return $resolved;
}
private function profileTypeLabel(string $class): string
{
return match ($class) {
\App\Models\Organization::class => __('Organization'),
\App\Models\Bank::class => __('Bank'),
\App\Models\Admin::class => __('Admin'),
default => '',
};
}
public function updateEstimatedRecipients()
{
// If no type is selected, set count to 0
if (empty($this->type)) {
$this->estimatedRecipientCount = 0;
$this->updateAvailableLocales();
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_contacts = $this->filterByContacts;
$tempMailing->contact_profile_ids = $this->filterByContacts
? collect($this->selectedContactProfiles)->map(fn($p) => ['id' => $p['id'], 'type' => $p['type']])->values()->all()
: [];
$tempMailing->filter_by_location = $this->filterByLocation;
$tempMailing->location_country_ids = $this->selectedCountryIds ?? [];
$tempMailing->location_division_ids = $this->selectedDivisionIds ?? [];
$tempMailing->location_city_ids = $this->selectedCityIds ?? [];
$tempMailing->location_district_ids = $this->selectedDistrictIds ?? [];
$this->estimatedRecipientCount = $tempMailing->getRecipientsCount();
$this->updateAvailableLocales();
}
// 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 updated($field)
{
$this->resetValidation($field);
if (in_array($field, ['sendToAuthUser', 'sendToActiveProfile', 'customTestEmail'])) {
$this->resetValidation('testRecipients');
}
}
public function updatedType()
{
$this->updateEstimatedRecipients();
}
public function updateRecipientCount()
{
$this->updateEstimatedRecipients();
}
public function updateAvailableLocales()
{
if (!empty($this->selectedPosts)) {
$tempMailing = new \App\Models\Mailing();
$tempMailing->content_blocks = $this->prepareContentBlocks();
$tempMailing->fallback_locale = $this->fallbackLocale ?: null;
$this->fallbackEligibleLocales = $tempMailing->getFallbackEligibleLocales();
// When a mailing type is selected, derive active locales from actual recipient groups
// so that location/profile-type filters are reflected in which subjects are required.
if (!empty($this->type)) {
$tempMailing->type = $this->type;
$tempMailing->filter_by_profile_type = $this->filterByProfileType;
$tempMailing->selected_profile_types = $this->selectedProfileTypes;
$tempMailing->filter_by_contacts = $this->filterByContacts;
$tempMailing->contact_profile_ids = $this->filterByContacts
? collect($this->selectedContactProfiles)->map(fn($p) => ['id' => $p['id'], 'type' => $p['type']])->values()->all()
: [];
$tempMailing->filter_by_location = $this->filterByLocation;
$tempMailing->location_country_ids = $this->selectedCountryIds ?? [];
$tempMailing->location_division_ids = $this->selectedDivisionIds ?? [];
$tempMailing->location_city_ids = $this->selectedCityIds ?? [];
// Show subject fields for all locales that have post content —
// test emails are sent in all content locales and each needs its own subject.
$this->availableLocales = $tempMailing->getAvailablePostLocales();
// Subjects are only required for locales that also have recipients
$countsByLocale = $tempMailing->getRecipientCountsByLocale();
$this->requiredLocales = collect($countsByLocale)
->filter(fn($data) => $data['has_content'])
->keys()
->values()
->toArray();
// Always ensure base language is present
if (empty($this->availableLocales)) {
$this->availableLocales = [timebank_config('base_language', 'en')];
}
if (empty($this->requiredLocales)) {
$this->requiredLocales = $this->availableLocales;
}
} else {
// No type selected yet — show all locales that have post translations
// Required = available since we don't know recipient counts yet
$this->availableLocales = $tempMailing->getAvailablePostLocales();
$this->requiredLocales = $this->availableLocales;
}
} else {
$this->availableLocales = [timebank_config('base_language', 'en')];
$this->requiredLocales = [timebank_config('base_language', 'en')];
$this->fallbackEligibleLocales = [];
}
// Clear selected fallback if it's no longer eligible
if ($this->fallbackLocale && !in_array($this->fallbackLocale, $this->fallbackEligibleLocales)) {
$this->fallbackLocale = '';
}
// Ensure subjects array has entries for all available locales
foreach ($this->availableLocales as $locale) {
if (!isset($this->subjects[$locale])) {
$this->subjects[$locale] = '';
}
}
// Remove subject keys for locales no longer in use so they don't trigger validation
foreach (array_keys($this->subjects) as $locale) {
if (!in_array($locale, $this->availableLocales)) {
unset($this->subjects[$locale]);
}
}
}
/**
* Get current recipient counts by locale based on form state
*/
public function getLocationFilterName(): ?string
{
if (!$this->filterByLocation) {
return null;
}
if (!empty($this->selectedDistrictIds)) {
$locale = \App\Models\Locations\DistrictLocale::where('district_id', $this->selectedDistrictIds[0])->first();
return $locale?->name;
}
if (!empty($this->selectedCityIds)) {
$locale = \App\Models\Locations\CityLocale::where('city_id', $this->selectedCityIds[0])->first();
return $locale?->name;
}
if (!empty($this->selectedDivisionIds)) {
$locale = \App\Models\Locations\DivisionLocale::where('division_id', $this->selectedDivisionIds[0])->first();
return $locale?->name;
}
if (!empty($this->selectedCountryIds)) {
$locale = \App\Models\Locations\CountryLocale::where('country_id', $this->selectedCountryIds[0])->first();
return $locale?->name;
}
return null;
}
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_contacts = $this->filterByContacts;
$tempMailing->contact_profile_ids = $this->filterByContacts
? collect($this->selectedContactProfiles)->map(fn($p) => ['id' => $p['id'], 'type' => $p['type']])->values()->all()
: [];
$tempMailing->filter_by_location = $this->filterByLocation;
$tempMailing->content_blocks = $this->prepareContentBlocks();
$tempMailing->fallback_locale = $this->fallbackLocale ?: null;
// Apply location filtering if enabled
if ($this->filterByLocation) {
$tempMailing->location_country_ids = $this->selectedCountryIds ?? [];
$tempMailing->location_division_ids = $this->selectedDivisionIds ?? [];
$tempMailing->location_city_ids = $this->selectedCityIds ?? [];
$tempMailing->location_district_ids = $this->selectedDistrictIds ?? [];
}
return $tempMailing->getRecipientCountsByLocale();
}
public function getCurrentRecipientCountsByLocaleAndLocation(): array
{
if (empty($this->type)) {
return [];
}
$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_contacts = $this->filterByContacts;
$tempMailing->contact_profile_ids = $this->filterByContacts
? collect($this->selectedContactProfiles)->map(fn($p) => ['id' => $p['id'], 'type' => $p['type']])->values()->all()
: [];
$tempMailing->filter_by_location = $this->filterByLocation;
$tempMailing->content_blocks = $this->prepareContentBlocks();
$tempMailing->fallback_locale = $this->fallbackLocale ?: null;
if ($this->filterByLocation) {
$tempMailing->location_country_ids = $this->selectedCountryIds ?? [];
$tempMailing->location_division_ids = $this->selectedDivisionIds ?? [];
$tempMailing->location_city_ids = $this->selectedCityIds ?? [];
$tempMailing->location_district_ids = $this->selectedDistrictIds ?? [];
}
return $tempMailing->getRecipientCountsByLocaleAndLocation();
}
public function getCurrentSuppressedCount(): int
{
if (empty($this->type)) {
return 0;
}
$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_contacts = $this->filterByContacts;
$tempMailing->contact_profile_ids = $this->filterByContacts
? collect($this->selectedContactProfiles)->map(fn($p) => ['id' => $p['id'], 'type' => $p['type']])->values()->all()
: [];
$tempMailing->filter_by_location = $this->filterByLocation;
if ($this->filterByLocation) {
$tempMailing->location_country_ids = $this->selectedCountryIds ?? [];
$tempMailing->location_division_ids = $this->selectedDivisionIds ?? [];
$tempMailing->location_city_ids = $this->selectedCityIds ?? [];
$tempMailing->location_district_ids = $this->selectedDistrictIds ?? [];
}
return $tempMailing->getSuppressedRecipientsCount();
}
public function saveMailing()
{
// CRITICAL: Authorize admin access for saving mailing
$this->authorizeAdminAccess();
$this->authorizePermission($this->editingMailing ? 'update mailings' : 'create mailings');
// Scheduling requires send permission (scheduling is equivalent to deferred sending)
if ($this->scheduledAt) {
$this->authorizePermission('send mailings');
}
// 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_contacts' => $this->filterByContacts,
'contact_profile_ids' => $this->filterByContacts
? collect($this->selectedContactProfiles)->map(fn($p) => ['id' => $p['id'], 'type' => $p['type']])->values()->all()
: [],
'filter_by_location' => $this->filterByLocation,
'location_country_ids' => $this->selectedCountryIds ?? [],
'location_division_ids' => $this->selectedDivisionIds ?? [],
'location_city_ids' => $this->selectedCityIds ?? [],
'location_district_ids' => $this->selectedDistrictIds ?? [],
// Keep singular columns in sync with the first selected value for backwards compatibility
'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,
'fallback_locale' => $this->fallbackLocale ?: 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->getRecipientsCount();
$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->getRecipientsCount();
$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();
$this->authorizePermission('delete mailings');
$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 openReportModal($mailingId)
{
$this->mailingForReport = Mailing::withTrashed()->findOrFail($mailingId);
$this->showReportModal = true;
}
public function refreshReport()
{
if ($this->mailingForReport) {
$this->mailingForReport = Mailing::withTrashed()->findOrFail($this->mailingForReport->id);
}
}
public function getReportBounceStats(): array
{
if (!$this->mailingForReport) {
return ['hard' => 0, 'soft' => 0, 'complaint' => 0, 'unknown' => 0, 'total' => 0];
}
return $this->mailingForReport->getBounceStats();
}
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();
$this->authorizePermission('send mailings');
if (!$this->mailingToSend) {
return;
}
$this->mailingToSend->update([
'status' => 'sending',
'sending_started_at' => now(),
'updated_by_user_id' => Auth::guard('web')->id(),
]);
// Dispatch locale-specific email jobs
$this->mailingToSend->dispatchLocaleSpecificJobs();
$mailingId = $this->mailingToSend->id;
$this->showSendConfirmModal = false;
$this->mailingToSend = null;
$this->openReportModal($mailingId);
$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();
$this->authorizePermission('delete mailings');
$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::withTrashed()->findOrFail($mailingId);
// Test mail is allowed for draft, scheduled, sending, and sent mailings
if (!in_array($mailing->status, ['draft', 'scheduled', 'sending', 'sent'])) {
$this->notification([
'title' => __('Error'),
'description' => __('Test mail can only be sent for draft, scheduled, sending, or sent 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;
$this->dispatch('load-preview', url: route('mailings.preview_render', $mailing->id));
}
public function openStopConfirmModal($mailingId)
{
$this->authorizeAdminAccess();
$this->authorizePermission('send mailings');
$mailing = Mailing::findOrFail($mailingId);
if ($mailing->status !== 'sending') {
$this->notification([
'title' => __('Error'),
'description' => __('This mailing cannot be stopped.'),
'icon' => 'error'
]);
return;
}
$this->mailingToStop = $mailing;
$this->showStopConfirmModal = true;
}
public function stopMailing()
{
$this->authorizeAdminAccess();
$this->authorizePermission('send mailings');
if (!$this->mailingToStop) {
return;
}
$this->mailingToStop->update([
'status' => 'aborted',
'sent_at' => now(),
'updated_by_user_id' => Auth::guard('web')->id(),
]);
$this->showStopConfirmModal = false;
$this->mailingToStop = null;
$this->notification([
'title' => __('Mailing stopped'),
'description' => __('The mailing has been stopped. In-progress batches will finish their current email and then halt.'),
'icon' => 'info'
]);
$this->dispatch('mailingUpdated');
}
public function cancelMailing($mailingId)
{
$this->authorizeAdminAccess();
$this->authorizePermission('send mailings');
$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->selectedPostIds = collect($this->selectedPosts)->pluck('post_id')->map(fn($id) => (int) $id)->toArray();
$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()
{
// Remove posts that were unchecked in the selector
$this->selectedPosts = collect($this->selectedPosts)
->filter(fn($p) => in_array((int) $p['post_id'], $this->selectedPostIds))
->values()
->toArray();
// Add newly checked posts that aren't already in the list
$existingIds = collect($this->selectedPosts)->pluck('post_id')->map(fn($id) => (int) $id)->toArray();
$newIds = array_diff($this->selectedPostIds, $existingIds);
if (!empty($newIds)) {
$posts = Post::whereIn('id', $newIds)->with('translations')->get();
foreach ($posts as $post) {
$this->selectedPosts[] = [
'post_id' => $post->id,
'order' => count($this->selectedPosts) + 1,
'title' => $post->translations->first()->title ?? __('Untitled')
];
}
}
// Re-index order
foreach ($this->selectedPosts as $i => $post) {
$this->selectedPosts[$i]['order'] = $i + 1;
}
$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)) {
$customEmails = collect(explode(',', $this->customTestEmail))
->map(fn($email) => trim($email))
->filter()
->values();
$invalidEmails = $customEmails->reject(fn($email) => filter_var($email, FILTER_VALIDATE_EMAIL));
if ($invalidEmails->isNotEmpty()) {
$this->addError('customTestEmail', __('Invalid email address(es): :emails', ['emails' => $invalidEmails->implode(', ')]));
return;
}
$testEmails = array_merge($testEmails, $customEmails->all());
}
// Check if at least one email is selected
if (empty($testEmails)) {
$this->addError('testRecipients', __('Please select at least one email address to send the test to.'));
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']);
}
/**
* Pick the best locale for preview:
* 1. Current app locale (if it has content for the mailing)
* 2. Locale with the most recipients that has content
* 3. Any locale that has content for the selected posts
*/
public function resolvePreviewLocale(): string
{
if (!$this->mailingForTest) {
return timebank_config('base_language', 'en');
}
return $this->mailingForTest->resolvePreviewLocale();
}
/**
* Generate HTML preview of the mailing
*/
public function getMailingPreviewHtml()
{
if (!$this->mailingForTest) {
return '<p class="text-sm text-gray-500">' . __('No mailing selected') . '</p>';
}
$locale = $this->resolvePreviewLocale();
// Generate content blocks for preview
$contentBlocks = [];
foreach ($this->mailingForTest->getContentBlocksForLocale($locale) as $block) {
if (!empty($block['post_missing'])) {
$contentBlocks[] = [
'type' => 'missing',
'data' => ['post_id' => $block['post_id']],
'template' => null,
];
continue;
}
$post = \App\Models\Post::withTrashed()->with(['translations', 'category', 'meeting.location.city.translations', 'poll'])->find($block['post_id']);
if (!$post) {
$contentBlocks[] = [
'type' => 'missing',
'data' => ['post_id' => $block['post_id']],
'template' => null,
];
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' => \App\Models\Mailing::blockTemplate($postType)
];
}
// Render the email view
return view('emails.newsletter.wrapper', [
'subject' => $this->mailingForTest->getSubjectForLocale($locale),
'mailingTitle' => $this->mailingForTest->title,
'mailingId' => $this->mailingForTest->id,
'locale' => $locale,
'contentBlocks' => $contentBlocks,
'unsubscribeUrl' => '#preview-unsubscribe',
'isTestMail' => false,
])->render();
}
/**
* Determine post type for preview
*/
private function determinePostType($post)
{
return \App\Models\Mailing::determinePostType($post);
}
/**
* 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,
'show_button' => $this->mailingForTest->type !== 'system_message'
&& !str_starts_with($post->category->type ?? '', 'SiteContents\\'),
];
// Poll posts link to the poll page and show whether the poll is open
$data = array_merge($data, \App\Models\Mailing::pollBlockData($post, $locale));
// 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;
$cityTranslations = $post->meeting->location?->city?->translations;
$data['city'] = $cityTranslations?->firstWhere('locale', $locale)?->name
?? $cityTranslations?->first()?->name
?? null;
}
// Add event date/time from meeting->from only; hide if not set
$eventFromDate = $post->meeting?->from;
if ($eventFromDate) {
$eventDate = \Carbon\Carbon::parse($eventFromDate);
$data['event_date'] = ucfirst($eventDate->locale($locale)->translatedFormat('j F'));
$data['event_time'] = $eventDate->locale($locale)->translatedFormat('H:i');
if ($post->meeting->till) {
$data['event_time_end'] = \Carbon\Carbon::parse($post->meeting->till)->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;
}
}