1021 lines
40 KiB
PHP
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');
|
|
}
|
|
}
|