string, 'title' => string, 'excerpt' => string] */ public array $newPoll = []; // Edit modal public bool $showEditModal = false; #[Locked] public ?int $editingPollId = null; /** Opened from the posts table (?from=posts): go back there when the edit modal closes */ #[Locked] public bool $returnToPosts = false; /** Post and questions are read-only once the poll left draft or someone voted (Poll::isContentLocked). */ #[Locked] public bool $contentLocked = false; /** Status when the modal was opened, to warn when a draft is being opened. */ #[Locked] public ?string $originalStatus = null; public string $tab = 'questions'; public string $editLocale = 'en'; public string $newQuestionType = ''; /** [['id' => ?int, 'key' => string, 'type' => string, 'label' => [locale => string], 'required' => bool, 'settings' => array]] */ public array $questions = []; /** The poll's post per locale: [locale => ['title' => ?string, 'excerpt' => ?string, 'content' => ?string]] */ public array $postFields = []; /** New image upload (replaces the current one on save) */ public $image = null; /** Remove the current image on save */ public bool $removeImage = false; public ?string $mediaOwner = null; /** [locale => caption] */ public array $mediaCaptions = []; /** Poll settings */ public array $form = []; /** Targeting */ public array $targeting = []; public ?array $matchingCounts = null; public function mount(): void { $this->authorizeAdminAccess(); $this->authorizePermission('access polls'); $this->editLocale = app()->getLocale(); $this->newQuestionType = array_key_first(app(QuestionTypeRegistry::class)->all()) ?? ''; // Opened from the posts table: /polls?edit={id} $editId = (int) request()->query('edit'); if ($editId && Poll::whereKey($editId)->exists()) { $this->returnToPosts = request()->query('from') === 'posts'; $this->openEditModal($editId); } } public function updatedSearch(): void { $this->resetPage(); } public function updatedStatusFilter(): void { $this->resetPage(); } public function sortBy(string $field): void { if (!in_array($field, ['id', 'status', 'opens_at', 'closes_at', 'responses_count', 'created_at'], true)) { return; } $this->sortDirection = $this->sortField === $field && $this->sortDirection === 'asc' ? 'desc' : 'asc'; $this->sortField = $field; } public function openCreateModal(): void { $this->authorizeAdminAccess(); $this->authorizePermission('create polls'); $this->newPoll = ['locale' => app()->getLocale(), 'title' => '', 'excerpt' => '']; $this->resetValidation(); $this->showCreateModal = true; } /** * Create the poll's post (Poll category, one translation) and a draft poll, * then continue in the Edit Poll modal. */ public function createPoll(): void { $this->authorizeAdminAccess(); $this->authorizePermission('create polls'); $locales = array_keys(config('laravellocalization.supportedLocales', ['en' => []])); $this->validate([ 'newPoll.locale' => ['required', Rule::in($locales)], 'newPoll.title' => timebank_config('posts.title_rule'), 'newPoll.excerpt' => timebank_config('posts.excerpt_rule'), ], [], [ 'newPoll.title' => __('Title'), 'newPoll.excerpt' => __('Introduction'), ]); $category = Category::where('type', Poll::class)->first(); if (!$category) { $this->addError('newPoll.title', __('The Poll category is missing. Run php artisan database:update.')); return; } $poll = DB::transaction(function () use ($category) { // Same postable rule as Posts/Manage $post = Post::create(timebank_config('posts.postable_is_auth_user') ? ['postable_id' => Auth::guard('web')->id(), 'postable_type' => get_class(Auth::guard('web')->user())] : ['postable_id' => session('activeProfileId'), 'postable_type' => session('activeProfileType')]); $post->category_id = $category->id; $post->save(); // The slug is generated from the title (Sluggable) $post->translations()->save(new PostTranslation([ 'locale' => $this->newPoll['locale'], 'title' => $this->newPoll['title'], 'excerpt' => $this->newPoll['excerpt'] ?: null, 'updated_by_user_id' => Auth::guard('web')->id(), ])); return Poll::create([ 'post_id' => $post->id, 'pollable_id' => session('activeProfileId'), 'pollable_type' => session('activeProfileType'), 'status' => Poll::STATUS_DRAFT, // New polls start anonymous; it can be switched off until the first vote 'anonymous' => true, ]); }); Log::info('Poll created', ['poll_id' => $poll->id, 'post_id' => $poll->post_id, 'user_id' => Auth::guard('web')->id()]); $this->showCreateModal = false; $this->newPoll = []; $this->openEditModal($poll->id); } /** * Hide a poll from the normal list. Meant for polls that cannot be deleted any more * (people voted); an open poll is closed first, so a running poll cannot disappear. * Nothing changes for members. Undone with restoreFromArchive(). */ public function archivePoll(int $pollId): void { $this->authorizeAdminAccess(); $this->authorizePermission('update polls'); $poll = Poll::findOrFail($pollId); if ($poll->status === Poll::STATUS_OPEN) { $this->notification()->error(__('Not archived'), __('Close the poll before archiving it.')); return; } $poll->update(['archived_at' => now()]); Log::info('Poll archived', ['poll_id' => $poll->id, 'user_id' => Auth::guard('web')->id()]); $this->notification()->success(__('Archived'), __('The poll has been archived. Choose "Archived" in the status filter to see it.')); } public function restoreFromArchive(int $pollId): void { $this->authorizeAdminAccess(); $this->authorizePermission('update polls'); Poll::findOrFail($pollId)->update(['archived_at' => null]); $this->notification()->success(__('Restored'), __('The poll is back in the list.')); } public function confirmDelete(int $pollId): void { $this->authorizeAdminAccess(); $this->authorizePermission('delete polls'); $this->deletingPollId = Poll::findOrFail($pollId)->id; $this->showDeleteModal = true; } /** * Delete a draft poll, with its post (both soft deletes). Once a poll has been opened it * is archived instead, and a poll with ledger entries is never deleted: its ballots and * their verification must stay. */ public function deletePoll(): void { $this->authorizeAdminAccess(); $this->authorizePermission('delete polls'); $poll = Poll::findOrFail($this->deletingPollId); $this->showDeleteModal = false; $this->deletingPollId = null; if ($poll->status !== Poll::STATUS_DRAFT || $poll->ledger()->exists() || $poll->hasResponses()) { $this->notification()->error(__('Not deleted'), __('Only draft polls can be deleted. Close this poll and archive it instead.')); return; } DB::transaction(function () use ($poll) { $post = $poll->post; $poll->delete(); $post?->delete(); }); Log::info('Poll deleted', ['poll_id' => $poll->id, 'post_id' => $poll->post_id, 'user_id' => Auth::guard('web')->id()]); $this->notification()->success(__('Deleted'), __('The poll has been deleted.')); } public function openEditModal(int $pollId): void { $this->authorizeAdminAccess(); $this->authorizePermission('access polls'); $poll = Poll::with('questions')->findOrFail($pollId); $this->editingPollId = $poll->id; $this->contentLocked = $poll->isContentLocked(); $this->originalStatus = $poll->status; $this->questions = $poll->questions->map(fn (PollQuestion $question) => [ 'id' => $question->id, 'key' => 'q' . $question->id, 'type' => $question->type, 'label' => $question->label ?? [], 'required' => (bool) $question->required, 'settings' => $question->settings ?? [], ])->all(); $this->loadPostFields($poll); $this->form = [ 'status' => $poll->status, 'opens_at' => $poll->opens_at?->format('Y-m-d H:i'), 'closes_at' => $poll->closes_at?->format('Y-m-d H:i'), 'vote_scope' => $poll->vote_scope, 'member_scope' => $poll->member_scope, 'allow_vote_change' => (bool) $poll->allow_vote_change, 'anonymous' => (bool) $poll->anonymous, 'results_visibility' => $poll->results_visibility, 'verification' => $poll->verification, ]; $this->targeting = [ 'target_profile_types' => $poll->target_profile_types ?? [], 'filter_by_location' => (bool) $poll->filter_by_location, 'location_country_ids' => $poll->location_country_ids ?? [], 'location_division_ids' => $poll->location_division_ids ?? [], 'location_city_ids' => $poll->location_city_ids ?? [], 'location_district_ids' => $poll->location_district_ids ?? [], 'last_login_from' => $poll->last_login_from?->toDateString(), 'last_login_until' => $poll->last_login_until?->toDateString(), 'registered_from' => $poll->registered_from?->toDateString(), 'registered_until' => $poll->registered_until?->toDateString(), ]; $this->matchingCounts = null; $this->tab = 'questions'; $this->resetValidation(); $this->showEditModal = true; $this->dispatch('loadLocationFilterData', data: [ 'countries' => $this->targeting['location_country_ids'], 'divisions' => $this->targeting['location_division_ids'], 'cities' => $this->targeting['location_city_ids'], 'districts' => $this->targeting['location_district_ids'], ]); } public function closeModal(): void { if ($this->returnToPosts) { $this->redirectRoute('posts.manage'); return; } $this->showEditModal = false; $this->editingPollId = null; $this->questions = []; $this->image = null; $this->resetValidation(); } /** The modal's close button and Escape set showEditModal directly */ public function updatedShowEditModal(bool $value): void { if (!$value && $this->returnToPosts) { $this->redirectRoute('posts.manage'); } } // --------------------------------------------------------------------- // Questions // --------------------------------------------------------------------- public function addQuestion(): void { $registry = app(QuestionTypeRegistry::class); if ($this->contentLocked || !$registry->has($this->newQuestionType)) { return; } $this->questions[] = [ 'id' => null, 'key' => 'new' . Str::lower(Str::random(8)), 'type' => $this->newQuestionType, 'label' => [], 'required' => true, 'settings' => $registry->get($this->newQuestionType)->defaultSettings(), ]; } public function removeQuestion(int $index): void { if ($this->contentLocked || !isset($this->questions[$index])) { return; } array_splice($this->questions, $index, 1); $this->resetValidation(); } public function moveQuestion(int $index, int $direction): void { $target = $index + ($direction < 0 ? -1 : 1); if ($this->contentLocked || !isset($this->questions[$index], $this->questions[$target])) { return; } [$this->questions[$index], $this->questions[$target]] = [$this->questions[$target], $this->questions[$index]]; $this->resetValidation(); } /** * A changed question type starts from that type's default settings. */ public function updatedQuestions($value, string $key): void { if (!preg_match('/^(\d+)\.type$/', $key, $match)) { return; } $registry = app(QuestionTypeRegistry::class); $index = (int) $match[1]; if ($this->contentLocked || !$registry->has((string) $value)) { return; } $this->questions[$index]['settings'] = $registry->get($value)->defaultSettings(); } /** Add an option / date slot to a question (used by the type editor partials). */ public function addListItem(int $index, string $list): void { if (!$this->editableList($index, $list)) { return; } $item = app(QuestionTypeRegistry::class)->get($this->questions[$index]['type'])->newListItem($list); if ($list === 'options') { $item['label'] = []; } $this->questions[$index]['settings'][$list][] = $item; } public function removeListItem(int $index, string $list, int $i): void { if (!$this->editableList($index, $list) || !isset($this->questions[$index]['settings'][$list][$i])) { return; } array_splice($this->questions[$index]['settings'][$list], $i, 1); $this->resetValidation(); } public function moveListItem(int $index, string $list, int $i, int $direction): void { $target = $i + ($direction < 0 ? -1 : 1); if (!$this->editableList($index, $list) || !isset($this->questions[$index]['settings'][$list][$i], $this->questions[$index]['settings'][$list][$target])) { return; } $items = &$this->questions[$index]['settings'][$list]; [$items[$i], $items[$target]] = [$items[$target], $items[$i]]; } private function editableList(int $index, string $list): bool { return !$this->contentLocked && in_array($list, self::LIST_SETTINGS, true) && isset($this->questions[$index]) && app(QuestionTypeRegistry::class)->has($this->questions[$index]['type']); } // --------------------------------------------------------------------- // Targeting // --------------------------------------------------------------------- #[On('locationFilterUpdated')] public function handleLocationFilterUpdate($locationData): void { $this->targeting['location_country_ids'] = array_map('intval', $locationData['countries'] ?? []); $this->targeting['location_division_ids'] = array_map('intval', $locationData['divisions'] ?? []); $this->targeting['location_city_ids'] = array_map('intval', $locationData['cities'] ?? []); $this->targeting['location_district_ids'] = array_map('intval', $locationData['districts'] ?? []); $this->matchingCounts = null; } public function updatedTargeting(): void { $this->matchingCounts = null; } /** * Count the profiles the current (unsaved) targeting matches, per profile type. */ public function countMatchingProfiles(): void { $this->authorizeAdminAccess(); $this->validate($this->targetingRules()); $poll = new Poll($this->targetingAttributes()); $counts = []; foreach (Poll::PROFILE_TYPES as $class) { if ($poll->targetsProfileType($class)) { $counts[class_basename($class)] = $poll->applyProfileFilters($class::query())->count(); } } $this->matchingCounts = $counts; } // --------------------------------------------------------------------- // Save // --------------------------------------------------------------------- public function savePoll(): void { $this->authorizeAdminAccess(); $this->authorizePermission('update polls'); $poll = Poll::findOrFail($this->editingPollId); // Re-checked from the database: the poll may have been opened or voted on since the modal was opened $this->contentLocked = $poll->isContentLocked(); $this->castNumericSettings(); $this->validate( array_merge( $this->contentLocked ? [] : $this->postRules($poll), $this->settingsRules(), $this->targetingRules(), $this->contentLocked ? [] : $this->questionRules() ), [], $this->attributeNames() ); if ($error = $this->lockedSettingError($poll)) { [$field, $message] = $error; $this->addError($field, $message); $this->tab = 'settings'; return; } if ($this->form['status'] === Poll::STATUS_DRAFT && $poll->hasResponses()) { $this->addError('form.status', __('People have already voted, so this poll cannot go back to draft.')); $this->tab = 'settings'; return; } if ($this->form['status'] === Poll::STATUS_OPEN && empty($this->questions)) { $this->addError('form.status', __('Add at least one question before opening the poll.')); $this->tab = 'settings'; return; } DB::transaction(function () use ($poll) { $poll->update(array_merge([ 'status' => $this->form['status'], 'opens_at' => $this->form['opens_at'] ?: null, 'closes_at' => $this->form['closes_at'] ?: null, 'vote_scope' => $this->form['vote_scope'], 'member_scope' => $this->form['member_scope'], 'allow_vote_change' => (bool) $this->form['allow_vote_change'], 'anonymous' => (bool) $this->form['anonymous'], 'results_visibility' => $this->form['results_visibility'], 'verification' => $this->form['verification'], ], $this->targetingAttributes())); if (!$this->contentLocked) { $this->savePostFields($poll); $this->syncQuestions($poll); } // Opening a poll publishes its post, unless a publication date was already set if ($poll->status === Poll::STATUS_OPEN) { PostTranslation::where('post_id', $poll->post_id) ->whereNull('from') ->update(['from' => $poll->opens_at ?? now()]); } }); // Closing seals the ballot ledger; time-based closing is sealed by polls:seal if ($poll->status === Poll::STATUS_CLOSED) { app(Ledger::class)->seal($poll); } // Media is stored outside the DB transaction (files on disk) if (!$this->contentLocked) { $this->savePostMedia($poll->post); $poll->post->searchable(); } Log::info('Poll saved', ['poll_id' => $poll->id, 'status' => $poll->status, 'user_id' => auth('web')->id()]); $late = $this->latePublications(); $notification = [ 'icon' => $late ? 'warning' : 'success', 'title' => __('Saved'), 'description' => $late ? __('The poll has been saved, but its post is published later: :dates. Until then the poll is not shown.', [ 'dates' => $this->formatLatePublications($late), ]) : __('The poll has been saved.'), ]; if ($this->returnToPosts) { // Shown by the posts table after the redirect session()->flash('posts.manage.notification', $notification); } else { $this->notification()->send($notification); } $this->closeModal(); } /** * Only the settings the question type knows (security audit F7): the form is client * state, so unknown keys sent along are dropped instead of stored. List items keep * only their own fields. */ private function knownSettings(string $type, array $settings): array { $registry = app(QuestionTypeRegistry::class); if (!$registry->has($type)) { return []; } $settings = array_intersect_key($settings, $registry->get($type)->defaultSettings()); $itemFields = ['options' => ['key', 'label'], 'slots' => ['key', 'starts_at', 'ends_at']]; foreach ($itemFields as $list => $fields) { if (isset($settings[$list]) && is_array($settings[$list])) { $settings[$list] = array_values(array_map( fn ($item) => array_intersect_key((array) $item, array_flip($fields)), $settings[$list] )); } } return $settings; } /** * Settings that votes or the seal depend on (security audit F3-F5): * - anonymity cannot be switched off once people voted (it was promised to them); * - the vote scope cannot change once people voted (the one-vote rule depends on it); * - a sealed poll keeps its status and dates (its result is final). * * @return array{0: string, 1: string}|null field and message, or null when allowed */ private function lockedSettingError(Poll $poll): ?array { if ($poll->hasResponses()) { if ($poll->anonymous && !$this->form['anonymous']) { return ['form.anonymous', __('People voted anonymously, so this poll stays anonymous.')]; } if ($this->form['vote_scope'] !== $poll->vote_scope) { return ['form.vote_scope', __('People have already voted, so how organizations, banks and admins vote cannot change any more.')]; } } if ($poll->sealed_at) { $changed = $this->form['status'] !== $poll->status || ($this->form['opens_at'] ?: null) !== $poll->opens_at?->format('Y-m-d H:i') || ($this->form['closes_at'] ?: null) !== $poll->closes_at?->format('Y-m-d H:i'); if ($changed) { return ['form.status', __('This poll is closed and sealed: its result is final, so its status and dates cannot change any more.')]; } } return null; } /** Locales the post can be written in. */ private function supportedLocales(): array { return array_keys(config('laravellocalization.supportedLocales', ['en' => []])); } private function loadPostFields(Poll $poll): void { $translations = $poll->post->translations()->get()->keyBy('locale'); $this->postFields = []; foreach ($this->supportedLocales() as $locale) { $translation = $translations->get($locale); $this->postFields[$locale] = [ 'title' => $translation?->title, 'excerpt' => $translation?->excerpt, 'content' => $translation?->content, ]; } $media = $poll->post->getFirstMedia('posts'); $this->image = null; $this->removeImage = false; $this->mediaOwner = $media?->getCustomProperty('owner'); $this->mediaCaptions = []; foreach ($this->supportedLocales() as $locale) { $this->mediaCaptions[$locale] = $media?->getCustomProperty('caption-' . $locale); } } /** * A title is required for every language that already exists, and for a new language * as soon as its intro or text is filled in. At least one language needs a title. */ private function postRules(Poll $poll): array { $existing = $poll->post->translations()->pluck('locale')->all(); $rules = []; foreach ($this->supportedLocales() as $locale) { $fields = $this->postFields[$locale] ?? []; $used = in_array($locale, $existing, true) || trim((string) ($fields['excerpt'] ?? '')) !== '' || trim(strip_tags((string) ($fields['content'] ?? ''))) !== ''; $titleRule = timebank_config('posts.title_rule'); $titleRule = is_array($titleRule) ? $titleRule : explode('|', $titleRule); if (!$used) { // An unused language may stay empty $titleRule = array_map(fn ($rule) => $rule === 'required' ? 'nullable' : $rule, $titleRule); } $rules["postFields.$locale.title"] = $titleRule; $rules["postFields.$locale.excerpt"] = timebank_config('posts.excerpt_rule'); $rules["postFields.$locale.content"] = timebank_config('posts.content_rule'); $rules["mediaCaptions.$locale"] = timebank_config('posts.media_caption_rule'); } $rules['image'] = timebank_config('posts.image_rule'); $rules['mediaOwner'] = timebank_config('posts.media_owner_rule'); return $rules; } /** * Update existing translations and add the languages that got a title. * New translations get the post's publication dates; opening the poll fills in empty ones. */ private function savePostFields(Poll $poll): void { $translations = $poll->post->translations()->get()->keyBy('locale'); $publication = $translations->first(); foreach ($this->postFields as $locale => $fields) { if (!in_array($locale, $this->supportedLocales(), true)) { continue; } $title = trim((string) ($fields['title'] ?? '')); $attributes = [ 'title' => $title, 'excerpt' => trim((string) ($fields['excerpt'] ?? '')) ?: null, 'content' => trim(strip_tags((string) ($fields['content'] ?? ''))) !== '' ? $fields['content'] : null, 'updated_by_user_id' => Auth::guard('web')->id(), ]; if ($translation = $translations->get($locale)) { $translation->update($attributes); } elseif ($title !== '') { // The slug is generated from the title (Sluggable) $poll->post->translations()->save(new PostTranslation($attributes + [ 'locale' => $locale, 'from' => $publication?->from, 'till' => $publication?->till, ])); } } } private function savePostMedia(Post $post): void { $captions = []; foreach ($this->supportedLocales() as $locale) { $captions['caption-' . $locale] = $this->mediaCaptions[$locale] ?? null; } $properties = ['owner' => $this->mediaOwner] + $captions; if ($this->image) { $post->clearMediaCollection('posts'); $post->addMedia($this->image->getRealPath()) ->withCustomProperties($properties) ->toMediaCollection('posts'); } elseif ($this->removeImage) { $post->clearMediaCollection('posts'); } elseif ($media = $post->getFirstMedia('posts')) { foreach ($properties as $name => $value) { $media->setCustomProperty($name, $value); } $media->save(); } $this->image = null; } /** Check the upload right away, like Posts/Manage. */ public function updatedImage(): void { if ($this->contentLocked) { $this->image = null; return; } $this->removeImage = false; $this->validateOnly('image', ['image' => timebank_config('posts.image_rule')]); } /** Mark the image for removal; it is removed when the poll is saved. */ public function markImageForRemoval(): void { if ($this->contentLocked) { return; } $this->image = null; $this->removeImage = true; } private function syncQuestions(Poll $poll): void { $keepIds = array_filter(array_column($this->questions, 'id')); $poll->questions()->whereNotIn('id', $keepIds)->delete(); foreach (array_values($this->questions) as $position => $question) { $attributes = [ 'position' => $position + 1, 'type' => $question['type'], 'label' => array_filter($question['label'] ?? [], fn ($label) => trim((string) $label) !== ''), 'required' => (bool) $question['required'], 'settings' => $this->knownSettings($question['type'], $question['settings'] ?? []), ]; // Scoped to this poll, so a tampered question id cannot touch another poll $existing = $question['id'] ? $poll->questions()->whereKey($question['id'])->first() : null; if ($existing) { $existing->update($attributes); } else { $poll->questions()->create($attributes); } } } /** * Number inputs arrive as strings; store integers (or null when empty). */ private function castNumericSettings(): void { foreach ($this->questions as $index => $question) { foreach ($question['settings'] ?? [] as $name => $value) { if ($value === '') { $this->questions[$index]['settings'][$name] = null; } elseif (is_string($value) && preg_match('/^-?\d+$/', $value)) { $this->questions[$index]['settings'][$name] = (int) $value; } } } } private function settingsRules(): array { return [ 'form.status' => ['required', Rule::in([Poll::STATUS_DRAFT, Poll::STATUS_OPEN, Poll::STATUS_CLOSED])], 'form.opens_at' => ['nullable', 'date'], 'form.closes_at' => ['nullable', 'date', 'after:form.opens_at'], 'form.vote_scope' => ['required', Rule::in([Poll::VOTE_SCOPE_PER_PROFILE, Poll::VOTE_SCOPE_PER_USER])], 'form.member_scope' => ['required', Rule::in([Poll::MEMBER_SCOPE_ALL_USERS, Poll::MEMBER_SCOPE_MANAGERS_COORDINATORS])], 'form.allow_vote_change' => ['boolean'], 'form.anonymous' => ['boolean'], 'form.results_visibility' => ['required', Rule::in([Poll::RESULTS_NEVER, Poll::RESULTS_AFTER_VOTE, Poll::RESULTS_AFTER_CLOSE])], 'form.verification' => ['required', Rule::in([Poll::VERIFICATION_OFF, Poll::VERIFICATION_RECEIPTS, Poll::VERIFICATION_PUBLIC])], ]; } private function targetingRules(): array { return [ 'targeting.target_profile_types' => ['array'], 'targeting.target_profile_types.*' => [Rule::in(Poll::PROFILE_TYPES)], 'targeting.filter_by_location' => ['boolean'], 'targeting.location_country_ids.*' => ['integer', 'exists:countries,id'], 'targeting.location_division_ids.*' => ['integer', 'exists:divisions,id'], 'targeting.location_city_ids.*' => ['integer', 'exists:cities,id'], 'targeting.location_district_ids.*' => ['integer', 'exists:districts,id'], 'targeting.last_login_from' => ['nullable', 'date'], 'targeting.last_login_until' => ['nullable', 'date', 'after_or_equal:targeting.last_login_from'], 'targeting.registered_from' => ['nullable', 'date'], 'targeting.registered_until' => ['nullable', 'date', 'after_or_equal:targeting.registered_from'], ]; } private function questionRules(): array { $registry = app(QuestionTypeRegistry::class); $rules = ['questions' => ['array', 'max:50']]; foreach ($this->questions as $index => $question) { $rules["questions.$index.type"] = ['required', Rule::in(array_keys($registry->all()))]; $rules["questions.$index.label"] = ['required', 'array', Localized::filledRule()]; $rules["questions.$index.label.*"] = ['nullable', 'string', 'max:300']; $rules["questions.$index.required"] = ['boolean']; if ($registry->has($question['type'])) { $rules += $registry->get($question['type'])->settingsRules("questions.$index.settings", $question['settings'] ?? []); } } return $rules; } private function attributeNames(): array { $names = ['image' => __('Image'), 'mediaOwner' => __('Image ownership')]; foreach ($this->supportedLocales() as $locale) { $suffix = ' (' . strtoupper($locale) . ')'; $names["postFields.$locale.title"] = __('Title') . $suffix; $names["postFields.$locale.excerpt"] = __('Introduction') . $suffix; $names["postFields.$locale.content"] = __('Content') . $suffix; $names["mediaCaptions.$locale"] = __('Caption') . $suffix; } foreach ($this->questions as $index => $question) { $names["questions.$index.label"] = __('Question') . ' ' . ($index + 1); // List items (options, date slots) are named by position, not by their array path foreach ($question['settings']['options'] ?? [] as $item => $option) { $names["questions.$index.settings.options.$item.label"] = __('Question :question, option :option', ['question' => $index + 1, 'option' => $item + 1]); } foreach ($question['settings']['slots'] ?? [] as $item => $slot) { $name = __('Question :question, date :date', ['question' => $index + 1, 'date' => $item + 1]); $names["questions.$index.settings.slots.$item.starts_at"] = $name; $names["questions.$index.settings.slots.$item.ends_at"] = $name; } } return $names; } private function targetingAttributes(): array { $t = $this->targeting; $location = !empty($t['filter_by_location']); return [ 'target_profile_types' => array_values($t['target_profile_types'] ?? []) ?: null, 'filter_by_location' => $location, 'location_country_ids' => $location ? ($t['location_country_ids'] ?? []) : [], 'location_division_ids' => $location ? ($t['location_division_ids'] ?? []) : [], 'location_city_ids' => $location ? ($t['location_city_ids'] ?? []) : [], 'location_district_ids' => $location ? ($t['location_district_ids'] ?? []) : [], 'last_login_from' => $t['last_login_from'] ?: null, 'last_login_until' => $t['last_login_until'] ?: null, 'registered_from' => $t['registered_from'] ?: null, 'registered_until' => $t['registered_until'] ?: null, ]; } // --------------------------------------------------------------------- // Render // --------------------------------------------------------------------- public function render() { $locale = app()->getLocale(); $polls = Poll::query() ->with(['post.translations']) ->withCount('responses') ->when($this->search !== '', function ($query) { $query->whereHas('post.translations', fn ($q) => $q->where('title', 'like', '%' . $this->search . '%')); }) // Archived polls only under the "Archived" filter ->when($this->statusFilter === 'archived', fn ($query) => $query->whereNotNull('archived_at'), fn ($query) => $query->whereNull('archived_at') ->when($this->statusFilter !== '', fn ($q) => $q->where('status', $this->statusFilter))) ->orderBy($this->sortField, $this->sortDirection) ->paginate(15); $editingPoll = $this->showEditModal && $this->editingPollId ? Poll::with('post.translations')->find($this->editingPollId) : null; $results = $editingPoll && $this->tab === 'results' ? $this->results($editingPoll) : null; $currentImage = $editingPoll && !$this->removeImage ? $editingPoll->post?->getFirstMediaUrl('posts') : null; return view('livewire.polls.manage', [ 'polls' => $polls, 'editingPoll' => $editingPoll, 'results' => $results, 'currentImage' => $currentImage ?: null, 'locale' => $locale, 'typeOptions' => app(QuestionTypeRegistry::class)->options(), 'registry' => app(QuestionTypeRegistry::class), 'locales' => array_keys(config('laravellocalization.supportedLocales', ['en' => []])), ]); } /** * Results tab: aggregates per question, voters (unless anonymous) and the ledger state. */ private function results(Poll $poll): array { // Who voted: only for 'export polls' (security audit F9), never for anonymous polls $voters = ($poll->anonymous || !Gate::allows('export polls')) ? collect() : $poll->responses() ->with(['voter', 'user']) ->orderByDesc('submitted_at') ->limit(500) ->get(); return [ 'questions' => $poll->questions()->get(), 'summaries' => $poll->questionSummaries(), 'voters' => $voters, 'may_export' => Gate::allows('export polls'), 'response_count' => $poll->responses()->count(), 'chain' => app(Ledger::class)->verifyChain($poll), 'active_ballots' => app(Ledger::class)->activeBallots($poll)->count(), ]; } /** * Post translations whose publication date is after the moment the poll opens: * the poll stays invisible in that language until then. * * @return array locale => publication date */ public function latePublications(): array { if (!$this->editingPollId || ($this->form['status'] ?? null) !== Poll::STATUS_OPEN) { return []; } $poll = Poll::find($this->editingPollId); $opensAt = !empty($this->form['opens_at']) ? \Illuminate\Support\Carbon::parse($this->form['opens_at']) : now(); return PostTranslation::where('post_id', $poll?->post_id) ->whereNotNull('from') ->where('from', '>', $opensAt) ->pluck('from', 'locale') ->map(fn ($from) => \Illuminate\Support\Carbon::parse($from)) ->all(); } /** "NL: 12-11-2026 10:00, EN: ..." */ public function formatLatePublications(array $late): string { return collect($late) ->map(fn ($from, $locale) => strtoupper($locale) . ': ' . $from->locale(app()->getLocale())->isoFormat('lll')) ->implode(', '); } /** Post title in the current locale, for the list and the modal title. */ public function pollTitle(?Poll $poll): string { $translations = $poll?->post?->translations ?? collect(); $translation = $translations->firstWhere('locale', app()->getLocale()) ?? $translations->first(); return $translation?->title ?? __('Untitled poll'); } }