mixed, 'text' => ?string] */ public array $answers = []; /** Shown once after submitting; only its fingerprint is stored. */ #[Locked] public ?string $receiptCode = null; #[Locked] public bool $receiptEmailed = false; public function mount(int $pollId, string $context = 'page'): void { $this->pollId = $pollId; $this->context = $context === 'card' ? 'card' : 'page'; $this->state = $this->resolveState(); if ($this->state === self::STATE_FORM) { $this->answers = $this->emptyAnswers(); } } /** * Change an existing vote (only when the poll allows it). */ public function changeVote(): void { [$poll, $profile, $user] = $this->voter(); if (!$poll || !$poll->allow_vote_change || !$poll->isTargetedAt($profile, $user)) { return; } $response = $poll->responseFor($profile, $user->id); $this->answers = $this->emptyAnswers($response); $this->receiptCode = null; $this->state = self::STATE_FORM; } public function submit(Ledger $ledger): void { [$poll, $profile, $user] = $this->voter(); if (!$poll || !$poll->isTargetedAt($profile, $user)) { $this->state = self::STATE_HIDDEN; return; } $questions = $poll->questions()->get(); $rules = []; $messages = []; foreach ($questions as $question) { $rules += $question->questionType()->answerRules("answers.{$question->id}", $question->settings ?? [], $question->required); $messages += $question->questionType()->answerMessages("answers.{$question->id}", $question->settings ?? []); } $this->validate($rules, $messages, $this->attributeNames($questions)); $normalized = []; foreach ($questions as $question) { $normalized[$question->id] = $question->questionType()->normalize($this->answers[$question->id] ?? [], $question->settings ?? []); } $receiptCode = $ledger->newReceiptCode(); try { $stored = DB::transaction(function () use ($poll, $profile, $user, $normalized, $ledger, $receiptCode) { // Serializes votes on this poll, so two managers of one organization cannot both vote Poll::whereKey($poll->id)->lockForUpdate()->value('id'); $response = $poll->responseFor($profile, $user->id); if ($response && !$poll->allow_vote_change) { return false; } $entry = $ledger->append( $poll, $response ? PollLedgerEntry::CHANGE : PollLedgerEntry::CAST, $ledger->ballotPayload($normalized), $ledger->fingerprint($receiptCode), $response?->ledger_sequence ); if ($response) { // user_id: the user who last submitted for this profile $response->update(['user_id' => $user->id, 'ledger_sequence' => $entry->sequence]); $response->answers()->delete(); } else { $response = PollResponse::create([ 'poll_id' => $poll->id, 'voter_id' => $profile->getKey(), 'voter_type' => get_class($profile), 'user_id' => $user->id, 'dedupe_key' => $poll->dedupeKey($profile, $user->id), 'ledger_sequence' => $entry->sequence, 'submitted_at' => now(), ]); } foreach ($normalized as $questionId => $answer) { if ($answer['value'] === null && $answer['text'] === null) { continue; // optional question left empty } PollAnswer::create([ 'poll_response_id' => $response->id, 'poll_question_id' => $questionId, 'value' => $answer['value'], 'text' => $answer['text'], ]); } return true; }); } catch (QueryException $e) { // Unique index on (poll_id, dedupe_key): a vote for this profile arrived first if ((int) ($e->errorInfo[1] ?? 0) !== 1062) { throw $e; } $stored = false; } if (!$stored) { $this->state = $this->resolveState(); return; } $this->receiptCode = $poll->verification === Poll::VERIFICATION_OFF ? null : $receiptCode; $this->receiptEmailed = false; $this->state = self::STATE_SUBMITTED; $this->dispatch('poll-submitted', pollId: $poll->id); } /** * Email the receipt to the user who voted. Sent right away, not queued: * a queued mail would store the code next to the address in the jobs table. */ public function emailReceipt(): void { if ($this->state !== self::STATE_SUBMITTED || !$this->receiptCode || $this->receiptEmailed) { return; } [$poll, , $user] = $this->voter(); if (!$poll || !$user?->email) { return; } try { Mail::to($user->email)->send(new PollReceiptMail($poll, $user, $this->receiptCode)); $this->receiptEmailed = true; } catch (\Throwable $e) { Log::warning('Poll receipt email failed', ['poll_id' => $poll->id, 'error' => $e->getMessage()]); $this->addError('receipt', __('The email could not be sent. Please copy the code instead.')); } } public function render() { $poll = $this->state === self::STATE_HIDDEN ? null : Poll::with('questions', 'post')->find($this->pollId); $response = null; $summaries = []; if ($poll && in_array($this->state, [self::STATE_VOTED, self::STATE_SUBMITTED, self::STATE_CLOSED], true)) { [, $profile, $user] = $this->voter(); $response = $profile && $user ? $poll->responseFor($profile, $user->id) : null; if ($poll->resultsVisibleToVoter($response !== null)) { $summaries = $poll->questionSummaries(); } } return view('livewire.polls.poll-form', [ 'poll' => $poll, 'response' => $response, 'summaries' => $summaries, 'locale' => app()->getLocale(), ]); } /** * @return array{0: ?Poll, 1: ?Model, 2: ?User} poll, active profile, web user behind it */ private function voter(): array { $poll = Poll::find($this->pollId); $profile = getActiveProfile(); $user = Auth::guard('web')->user(); if (!$poll || !$profile || !$user instanceof User) { return [null, null, null]; } return [$poll, $profile, $user]; } private function resolveState(): string { [$poll, $profile, $user] = $this->voter(); if (!$poll || !$this->allTypesAvailable($poll)) { return self::STATE_HIDDEN; } // After closing, the post page shows the outcome to the profiles the poll was for if ($poll->isClosed()) { return $this->context === 'page' && $poll->matchesProfile($profile) && $poll->memberMayVote($profile, $user) ? self::STATE_CLOSED : self::STATE_HIDDEN; } if (!$poll->isTargetedAt($profile, $user)) { return self::STATE_HIDDEN; } if ($poll->responseFor($profile, $user->id) === null) { return self::STATE_FORM; } // The main-page card disappears once voted; the post page shows the vote return $this->context === 'card' ? self::STATE_HIDDEN : self::STATE_VOTED; } /** * A question whose type was disabled in the platform config cannot be answered. */ private function allTypesAvailable(Poll $poll): bool { foreach ($poll->questions()->pluck('type') as $type) { if (!app(\App\Polls\QuestionTypeRegistry::class)->has($type)) { Log::warning('Poll hidden: question type is disabled', ['poll_id' => $poll->id, 'type' => $type]); return false; } } return true; } /** * Empty answers per question, or the stored answers when changing a vote. */ private function emptyAnswers(?PollResponse $response = null): array { $stored = $response ? $response->answers()->get()->keyBy('poll_question_id') : collect(); $answers = []; foreach (Poll::find($this->pollId)->questions()->get() as $question) { $listValue = in_array($question->type, ['multi_choice', 'ranking', 'date_time_slots'], true); $answer = $stored->get($question->id); $answers[$question->id] = [ 'value' => $answer?->value ?? ($listValue ? [] : null), 'text' => $answer?->text, ]; } return $answers; } /** * Validation messages name the question instead of "answers.12.value". */ private function attributeNames($questions): array { $names = []; foreach ($questions as $question) { $label = $question->labelFor(); $names["answers.{$question->id}.value"] = $label; $names["answers.{$question->id}.text"] = $label; } return $names; } }