1609 lines
60 KiB
PHP
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;
|
|
}
|
|
} |