Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,309 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Polls;
|
||||
|
||||
use App\Mail\PollReceiptMail;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollAnswer;
|
||||
use App\Models\PollLedgerEntry;
|
||||
use App\Models\PollResponse;
|
||||
use App\Models\User;
|
||||
use App\Polls\Ledger;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Answer a poll as the active profile.
|
||||
*
|
||||
* Hidden when the profile is not targeted. After voting, the main-page card
|
||||
* ($context = 'card') disappears on the next page load; the post page shows
|
||||
* "You voted on ..." with a change button when the poll allows it.
|
||||
*/
|
||||
class PollForm extends Component
|
||||
{
|
||||
public const STATE_HIDDEN = 'hidden';
|
||||
public const STATE_FORM = 'form';
|
||||
public const STATE_VOTED = 'voted';
|
||||
public const STATE_SUBMITTED = 'submitted';
|
||||
public const STATE_CLOSED = 'closed';
|
||||
|
||||
#[Locked]
|
||||
public int $pollId;
|
||||
|
||||
#[Locked]
|
||||
public string $context = 'page';
|
||||
|
||||
#[Locked]
|
||||
public string $state = self::STATE_HIDDEN;
|
||||
|
||||
/** answers[question id] = ['value' => 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Polls;
|
||||
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollAnswer;
|
||||
use App\Models\PollResponse;
|
||||
use App\Polls\Ledger;
|
||||
use App\Polls\QuestionTypeRegistry;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* "Check my receipt" on the verification page: shows the ballot behind a receipt code
|
||||
* and whether it counts, was replaced by a changed vote, or was withdrawn.
|
||||
*/
|
||||
class ReceiptCheck extends Component
|
||||
{
|
||||
#[Locked]
|
||||
public int $pollId;
|
||||
|
||||
public string $code = '';
|
||||
|
||||
#[Locked]
|
||||
public ?array $result = null;
|
||||
|
||||
#[Locked]
|
||||
public bool $notFound = false;
|
||||
|
||||
public function mount(int $pollId): void
|
||||
{
|
||||
$this->pollId = $pollId;
|
||||
}
|
||||
|
||||
public function check(Ledger $ledger): void
|
||||
{
|
||||
$this->validate(['code' => ['required', 'string', 'max:40']], [], ['code' => __('Receipt code')]);
|
||||
|
||||
// Codes cannot be guessed (about 99 bits), but checks are limited anyway (security audit F6)
|
||||
$limiterKey = 'poll-receipt-check:' . (auth('web')->id() ?? request()->ip());
|
||||
if (RateLimiter::tooManyAttempts($limiterKey, 20)) {
|
||||
$this->addError('code', __('Too many checks. Please try again in a minute.'));
|
||||
return;
|
||||
}
|
||||
RateLimiter::hit($limiterKey, 60);
|
||||
|
||||
$poll = Poll::findOrFail($this->pollId);
|
||||
$this->result = null;
|
||||
$this->notFound = false;
|
||||
|
||||
$found = $poll->verification !== Poll::VERIFICATION_OFF ? $ledger->lookupReceipt($poll, $this->code) : null;
|
||||
if (!$found) {
|
||||
$this->notFound = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$entry = $found['entry'];
|
||||
$questions = $poll->questions()->get()->keyBy('id');
|
||||
$registry = app(QuestionTypeRegistry::class);
|
||||
|
||||
// Texts are in poll_answers only while this ballot is the voter's current one
|
||||
$texts = [];
|
||||
$response = PollResponse::where('poll_id', $poll->id)->where('ledger_sequence', $entry->sequence)->first();
|
||||
if ($response) {
|
||||
$texts = PollAnswer::where('poll_response_id', $response->id)->pluck('text', 'poll_question_id')->all();
|
||||
}
|
||||
|
||||
$answers = [];
|
||||
foreach ($entry->payload['answers'] ?? [] as $answer) {
|
||||
$question = $questions->get($answer['question_id']);
|
||||
if (!$question || !$registry->has($question->type)) {
|
||||
continue;
|
||||
}
|
||||
$text = $texts[$question->id] ?? null;
|
||||
$textHash = $answer['text_sha256'] ?? null;
|
||||
$textMatches = $textHash && $text !== null && hash_equals($textHash, hash('sha256', $text));
|
||||
$shownText = $textMatches ? $text : ($textHash ? '[' . __('text') . ']' : null);
|
||||
|
||||
$answers[] = [
|
||||
'question' => $question->labelFor(App::getLocale()),
|
||||
'answer' => $question->questionType()->answerToText($answer['value'] ?? null, $shownText, $question->settings ?? [], App::getLocale()),
|
||||
'text_checked' => $textMatches,
|
||||
];
|
||||
}
|
||||
|
||||
$this->result = [
|
||||
'sequence' => $entry->sequence,
|
||||
'status' => $found['status'],
|
||||
'recorded_at' => $entry->recorded_at,
|
||||
'answers' => $answers,
|
||||
];
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.polls.receipt-check');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user