Snapshot of main at 9f39a6e1e (2026-11-02)

This commit is contained in:
Ronald Huynen committed 2026-11-02 10:41:47 +01:00
commit 6ccf1792d7
2580 files changed
+1040202

No files matched your search

+309
View File
@@ -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;
}
}