310 lines
11 KiB
PHP
310 lines
11 KiB
PHP
<?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;
|
|
}
|
|
}
|