Files

1021 lines
40 KiB
PHP

<?php
namespace App\Http\Livewire\Polls;
use App\Http\Livewire\Traits\RequiresAdminAuthorization;
use App\Models\Category;
use App\Models\Poll;
use App\Models\PollQuestion;
use App\Models\Post;
use App\Models\PostTranslation;
use App\Polls\Ledger;
use App\Polls\Localized;
use App\Polls\QuestionTypeRegistry;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Component;
use Livewire\WithFileUploads;
use Livewire\WithPagination;
use WireUi\Traits\WireUiActions;
/**
* Admin page for polls: list, a New Poll modal (creates the post in the Poll category
* with a title and intro) and an Edit Poll modal with questions, settings, targeting
* and results. Image and longer text of the post are edited in Posts/Manage.
*/
class Manage extends Component
{
use WithPagination, WithFileUploads, WireUiActions, RequiresAdminAuthorization;
private const LIST_SETTINGS = ['options', 'slots'];
// List
public $search = '';
public $statusFilter = '';
public $sortField = 'created_at';
public $sortDirection = 'desc';
// Delete confirmation
public bool $showDeleteModal = false;
#[Locked]
public ?int $deletingPollId = null;
// New poll modal
public bool $showCreateModal = false;
/** ['locale' => 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<string, \Illuminate\Support\Carbon> 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');
}
}