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

+89
View File
@@ -0,0 +1,89 @@
<?php
namespace App\Polls\Contracts;
use Illuminate\Support\Collection;
/**
* A poll question type (yes/no, multi-select, ranking...).
*
* Adding a type: implement this interface (usually by extending AbstractQuestionType),
* add the editor/input/results partials in resources/views/polls/types/{key}/
* and register the class under 'polls.question_types' in the platform config.
*
* An answer arrives from the poll form as ['value' => mixed, 'text' => ?string].
*
* Partials receive:
* - editor: $type, $settings, $prefix ('questions.{index}.settings'), $index, $locale.
* List items (options, slots) are added/removed/moved through the editor
* component's addListItem($index, $list), removeListItem($index, $list, $i)
* and moveListItem($index, $list, $i, $direction) methods.
* - input: $type, $question, $settings, $prefix ('answers.{question id}'), $locale.
* - results: $type, $question, $settings, $summary (from aggregate()), $locale.
*/
interface QuestionType
{
/** Registry key, stored in poll_questions.type. */
public function key(): string;
/** Translated name shown in the poll editor. */
public function label(): string;
/** Settings a new question of this type starts with. */
public function defaultSettings(): array;
/**
* Validation rules for the settings in the poll editor.
*
* @param string $prefix e.g. 'questions.0.settings'
* @param array $settings the settings being validated (some limits depend on them)
*/
public function settingsRules(string $prefix, array $settings): array;
/**
* A new item for a settings list, e.g. an option or a date slot.
*/
public function newListItem(string $list): array;
/**
* Validation rules for one answer in the poll form.
*
* @param string $prefix e.g. 'answers.12'
*/
public function answerRules(string $prefix, array $settings, bool $required): array;
/**
* Custom validation messages for the rules of answerRules(), keyed like Laravel's messages.
*
* @param string $prefix e.g. 'answers.12'
*/
public function answerMessages(string $prefix, array $settings): array;
/**
* Turn a validated answer into what is stored.
*
* @return array{value: mixed, text: ?string}
*/
public function normalize(array $answer, array $settings): array;
/**
* Summarise the stored answers of one question for the results view.
*
* @param Collection<int, \App\Models\PollAnswer> $answers
*/
public function aggregate(Collection $answers, array $settings): array;
/**
* One stored answer as readable text, e.g. for the CSV export.
*/
public function answerToText(mixed $value, ?string $text, array $settings, ?string $locale = null): string;
/** Blade partial for the settings in the poll editor. */
public function editorView(): string;
/** Blade partial for answering in the poll form. */
public function inputView(): string;
/** Blade partial for the results. */
public function resultsView(): string;
}
+327
View File
@@ -0,0 +1,327 @@
<?php
namespace App\Polls;
use App\Models\Poll;
use App\Models\PollLedgerEntry;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* The append-only ballot ledger of a poll: a SHA-256 hash chain.
*
* hash = SHA-256 of the canonical JSON of
* {v, poll_id, sequence, entry_type, fingerprint, refers_to_sequence, payload, recorded_at, previous_hash}
* Canonical JSON: object keys sorted (byte order), lists kept in order, no whitespace,
* unicode and slashes unescaped. The first entry's previous_hash is 64 zeros.
* This format is published on the verification page and recomputed there in the browser.
* Each entry stores the format_version it was hashed with; a new format must bump
* FORMAT_VERSION and keep hashing older entries the old way.
*/
class Ledger
{
public const FORMAT_VERSION = 1;
public const GENESIS_HASH = '0000000000000000000000000000000000000000000000000000000000000000';
/** Receipt alphabet without look-alikes (no 0/O, 1/I/L). */
private const RECEIPT_ALPHABET = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';
/**
* Append an entry. Must run inside the caller's DB transaction, so the ledger entry
* and the poll_responses / poll_answers rows are written together or not at all.
*/
public function append(
Poll $poll,
string $type,
?array $payload = null,
?string $fingerprint = null,
?int $refersToSequence = null
): PollLedgerEntry {
if (DB::transactionLevel() === 0) {
throw new \LogicException('Ledger::append() must run inside a database transaction.');
}
// Serialize appends per poll: the row lock makes concurrent votes queue up
Poll::whereKey($poll->id)->lockForUpdate()->value('id');
$last = PollLedgerEntry::where('poll_id', $poll->id)->orderByDesc('sequence')->first();
$attributes = [
'poll_id' => $poll->id,
'sequence' => $last ? $last->sequence + 1 : 1,
'format_version' => self::FORMAT_VERSION,
'entry_type' => $type,
'fingerprint' => $fingerprint,
'refers_to_sequence' => $refersToSequence,
'payload' => $payload,
'recorded_at' => $this->recordedAt(),
'previous_hash' => $last ? $last->hash : self::GENESIS_HASH,
];
$attributes['hash'] = $this->hash($attributes);
return PollLedgerEntry::create($attributes);
}
/**
* Ballot payload: the normalized answers as a list ordered by question id.
* Free text is stored as its SHA-256 only: the public ledger must not reveal it (it could
* identify people), while the hash still commits to it. The text itself is in poll_answers.
*
* @param array<int, array{value: mixed, text: ?string}> $normalized keyed by question id
*/
public function ballotPayload(array $normalized): array
{
ksort($normalized);
$answers = [];
foreach ($normalized as $questionId => $answer) {
$text = $answer['text'] ?? null;
$answers[] = [
'question_id' => (int) $questionId,
'value' => $answer['value'] ?? null,
'text_sha256' => $text !== null && $text !== '' ? hash('sha256', $text) : null,
];
}
return ['answers' => $answers];
}
/**
* Type-neutral tally: how often each distinct value was given per question, as a sorted list of
* {question_id, value (canonical JSON), count}. A list rather than a map, so PHP and the browser
* encode it identically (PHP turns maps with keys 0, 1, 2... into JSON lists).
*
* @param iterable<array{question_id: int, value: mixed}> $answers
* @return array<int, array{question_id: int, value: string, count: int}>
*/
public function tally(iterable $answers): array
{
$counts = [];
foreach ($answers as $answer) {
if (($answer['value'] ?? null) === null) {
continue;
}
$key = (int) $answer['question_id'] . "\0" . $this->canonical($answer['value']);
$counts[$key] = ($counts[$key] ?? 0) + 1;
}
$tally = [];
foreach ($counts as $key => $count) {
[$questionId, $value] = explode("\0", $key, 2);
$tally[] = ['question_id' => (int) $questionId, 'value' => $value, 'count' => $count];
}
// Byte order (strcmp), not PHP's numeric-string comparison: the browser sorts the same way
usort($tally, fn ($a, $b) => ($a['question_id'] <=> $b['question_id']) ?: strcmp($a['value'], $b['value']));
return $tally;
}
/** Tally of the ballots that count in the ledger. */
public function ledgerTally(Poll $poll): array
{
return $this->tally($this->activeBallots($poll)->flatMap(fn ($entry) => $entry->payload['answers'] ?? []));
}
/** Tally of the published results (poll_answers of the current votes). */
public function publishedTally(Poll $poll): array
{
$answers = \App\Models\PollAnswer::whereIn('poll_response_id', $poll->responses()->select('id'))
->get(['poll_question_id', 'value'])
->map(fn ($answer) => ['question_id' => $answer->poll_question_id, 'value' => $answer->value]);
return $this->tally($answers);
}
/**
* Seal a closed poll: a final entry committing to the number of counted ballots and the tally.
* Idempotent; withdrawals after the seal (deleted profiles) are appended after it.
*/
public function seal(Poll $poll): ?PollLedgerEntry
{
return DB::transaction(function () use ($poll) {
$poll = Poll::whereKey($poll->id)->lockForUpdate()->first();
if (!$poll || $poll->sealed_at) {
return null;
}
$entry = $this->append($poll, PollLedgerEntry::SEAL, [
'active_ballots' => $this->activeBallots($poll)->count(),
'tally_sha256' => hash('sha256', $this->canonical($this->ledgerTally($poll))),
]);
$poll->update(['sealed_at' => now(), 'seal_hash' => $entry->hash]);
return $entry;
});
}
/**
* An entry as published in the ledger JSON: exactly the hashed fields, plus its hash.
*/
public function publicEntry(PollLedgerEntry $entry): array
{
return [
'v' => (int) $entry->format_version,
'poll_id' => (int) $entry->poll_id,
'sequence' => (int) $entry->sequence,
'entry_type' => $entry->entry_type,
'fingerprint' => $entry->fingerprint,
'refers_to_sequence' => $entry->refers_to_sequence !== null ? (int) $entry->refers_to_sequence : null,
'payload' => $entry->payload,
'recorded_at' => $entry->recorded_at,
'previous_hash' => $entry->previous_hash,
'hash' => $entry->hash,
];
}
/** SHA-256 of the canonical form of an entry. */
public function hash(array $entry): string
{
$version = (int) ($entry['format_version'] ?? self::FORMAT_VERSION);
if ($version !== self::FORMAT_VERSION) {
throw new \UnexpectedValueException("Unknown poll ledger format version [$version]");
}
return hash('sha256', $this->canonical([
'v' => $version,
'poll_id' => (int) $entry['poll_id'],
'sequence' => (int) $entry['sequence'],
'entry_type' => $entry['entry_type'],
'fingerprint' => $entry['fingerprint'] ?? null,
'refers_to_sequence' => isset($entry['refers_to_sequence']) ? (int) $entry['refers_to_sequence'] : null,
'payload' => $entry['payload'] ?? null,
'recorded_at' => $this->timestamp($entry['recorded_at']),
'previous_hash' => $entry['previous_hash'],
]));
}
public function canonical($value): string
{
return json_encode(
$this->sortKeys($value),
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_LINE_TERMINATORS | JSON_THROW_ON_ERROR
);
}
private function sortKeys($value)
{
if (!is_array($value)) {
return $value;
}
if (!array_is_list($value)) {
ksort($value, SORT_STRING);
}
return array_map(fn ($item) => $this->sortKeys($item), $value);
}
/**
* When an entry is recorded: the start of the day, in the site's time zone (security audit
* F10). The ledger is public and an exact time could link a ballot to someone known to
* have voted then; the order is kept by the sequence numbers. Part of the hash, so it is
* rounded when recorded, not when shown.
*/
public function recordedAt(): string
{
return $this->timestamp(now(config('app.timezone'))->startOfDay());
}
/** UTC, second precision: 2026-10-10T17:00:00Z (stored as is, so no timezone conversion on read) */
public function timestamp(\DateTimeInterface|string $dateTime): string
{
if (is_string($dateTime)) {
return $dateTime;
}
return \Illuminate\Support\Carbon::instance($dateTime)->utc()->format('Y-m-d\TH:i:s\Z');
}
/**
* A new receipt code: 20 characters in groups of five, e.g. 7KQ2M-XH4PD-...
*/
public function newReceiptCode(): string
{
$code = '';
$max = strlen(self::RECEIPT_ALPHABET) - 1;
for ($i = 0; $i < 20; $i++) {
$code .= self::RECEIPT_ALPHABET[random_int(0, $max)];
}
return implode('-', str_split($code, 5));
}
/** Fingerprint stored in the ledger; tolerant of case, spaces and dashes in the entered code. */
public function fingerprint(string $receiptCode): string
{
return hash('sha256', preg_replace('/[^A-Z0-9]/', '', Str::upper($receiptCode)));
}
/**
* Recompute the whole chain.
*
* @return array{valid: bool, entries: int, broken_at: ?int, head: string}
*/
public function verifyChain(Poll $poll): array
{
$previous = self::GENESIS_HASH;
$expectedSequence = 1;
$count = 0;
foreach (PollLedgerEntry::where('poll_id', $poll->id)->orderBy('sequence')->cursor() as $entry) {
$attributes = $entry->getAttributes();
$attributes['payload'] = $entry->payload;
if ($entry->sequence !== $expectedSequence
|| $entry->previous_hash !== $previous
|| !hash_equals($this->hash($attributes), $entry->hash)) {
return ['valid' => false, 'entries' => $count, 'broken_at' => $entry->sequence, 'head' => $previous];
}
$previous = $entry->hash;
$expectedSequence++;
$count++;
}
return ['valid' => true, 'entries' => $count, 'broken_at' => null, 'head' => $previous];
}
/**
* Ballots that count: cast / change entries not replaced by a later change or withdraw.
*
* @return Collection<int, PollLedgerEntry>
*/
public function activeBallots(Poll $poll): Collection
{
$entries = PollLedgerEntry::where('poll_id', $poll->id)->orderBy('sequence')->get();
$replaced = $entries->pluck('refers_to_sequence')->filter()->flip();
return $entries->filter(fn ($entry) => $entry->isBallot() && !$replaced->has($entry->sequence))->values();
}
/**
* Status of the ballot behind a receipt code.
*
* @return array{entry: PollLedgerEntry, status: string, replaced_by: ?PollLedgerEntry}|null
* status: 'counted' | 'replaced' | 'withdrawn'; null when the code is unknown for this poll
*/
public function lookupReceipt(Poll $poll, string $receiptCode): ?array
{
$entry = PollLedgerEntry::where('poll_id', $poll->id)
->where('fingerprint', $this->fingerprint($receiptCode))
->first();
if ($entry === null) {
return null;
}
$replacedBy = PollLedgerEntry::where('poll_id', $poll->id)
->where('refers_to_sequence', $entry->sequence)
->first();
$status = match ($replacedBy?->entry_type) {
null => 'counted',
PollLedgerEntry::WITHDRAW => 'withdrawn',
default => 'replaced',
};
return ['entry' => $entry, 'status' => $status, 'replaced_by' => $replacedBy];
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Polls;
/**
* Picks a label from a per-locale array: {"en": "...", "nl": "..."}.
*/
class Localized
{
/**
* The label in the given (or current) locale, falling back to the app fallback
* locale and then to the first label that is filled in.
*/
public static function pick(?array $labels, ?string $locale = null): string
{
$labels = array_filter($labels ?? [], fn ($label) => is_string($label) && trim($label) !== '');
$locale = $locale ?? app()->getLocale();
return $labels[$locale]
?? $labels[config('app.fallback_locale')]
?? (string) reset($labels);
}
/**
* Validation rule: at least one locale has a label.
*/
public static function filledRule(): \Closure
{
return function (string $attribute, $value, \Closure $fail) {
if (!is_array($value) || self::pick($value) === '') {
$fail(__('Fill in at least one language.'));
}
};
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace App\Polls;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\Organization;
use App\Models\Poll;
use App\Models\PollLedgerEntry;
use App\Models\Post;
use App\Models\PostTranslation;
use App\Models\User;
use Illuminate\Support\Carbon;
/**
* Poll definitions in post backups (BackupPosts / RestorePosts / Posts\BackupRestore).
* Only the definition travels: no votes and no ledger. A restored poll is a draft.
* Location targeting is left out: location ids differ between databases.
*/
class PollBackup
{
private const OWNER_TYPES = [User::class, Organization::class, Bank::class, Admin::class];
public static function export(Poll $poll): array
{
return [
'owner_type' => $poll->pollable_type,
'owner_name' => $poll->pollable?->name,
'opens_at' => $poll->opens_at?->toDateTimeString(),
'closes_at' => $poll->closes_at?->toDateTimeString(),
'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,
'target_profile_types' => $poll->target_profile_types,
'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(),
'questions' => $poll->questions->map(fn ($question) => [
'position' => $question->position,
'type' => $question->type,
'label' => $question->label,
'settings' => $question->settings,
'required' => (bool) $question->required,
])->values()->all(),
];
}
/**
* Recreate a poll from a backup as a draft. The owner is looked up by type and name.
*/
public static function import(Post $post, array $data): Poll
{
$owner = null;
$ownerType = $data['owner_type'] ?? null;
// Whitelist: never instantiate an arbitrary class from a backup file
if (in_array($ownerType, self::OWNER_TYPES, true) && !empty($data['owner_name'])) {
$owner = $ownerType::where('name', $data['owner_name'])->first();
}
$pick = fn (string $key, array $allowed, string $default) => in_array($data[$key] ?? null, $allowed, true) ? $data[$key] : $default;
$profileTypes = array_values(array_intersect((array) ($data['target_profile_types'] ?? []), Poll::PROFILE_TYPES));
$poll = Poll::create([
'post_id' => $post->id,
'pollable_id' => $owner?->getKey(),
'pollable_type' => $owner ? $ownerType : null,
'status' => Poll::STATUS_DRAFT,
'opens_at' => self::date($data['opens_at'] ?? null),
'closes_at' => self::date($data['closes_at'] ?? null),
'vote_scope' => $pick('vote_scope', [Poll::VOTE_SCOPE_PER_PROFILE, Poll::VOTE_SCOPE_PER_USER], Poll::VOTE_SCOPE_PER_PROFILE),
'member_scope' => $pick('member_scope', [Poll::MEMBER_SCOPE_ALL_USERS, Poll::MEMBER_SCOPE_MANAGERS_COORDINATORS], Poll::MEMBER_SCOPE_ALL_USERS),
'allow_vote_change' => (bool) ($data['allow_vote_change'] ?? false),
'anonymous' => (bool) ($data['anonymous'] ?? false),
'results_visibility' => $pick('results_visibility', [Poll::RESULTS_NEVER, Poll::RESULTS_AFTER_VOTE, Poll::RESULTS_AFTER_CLOSE], Poll::RESULTS_NEVER),
'verification' => $pick('verification', [Poll::VERIFICATION_OFF, Poll::VERIFICATION_RECEIPTS, Poll::VERIFICATION_PUBLIC], Poll::VERIFICATION_PUBLIC),
'filter_by_location' => false,
'target_profile_types' => $profileTypes ?: null,
'last_login_from' => self::date($data['last_login_from'] ?? null),
'last_login_until' => self::date($data['last_login_until'] ?? null),
'registered_from' => self::date($data['registered_from'] ?? null),
'registered_until' => self::date($data['registered_until'] ?? null),
]);
foreach ((array) ($data['questions'] ?? []) as $index => $question) {
if (!is_array($question) || !is_string($question['type'] ?? null)) {
continue;
}
$poll->questions()->create([
'position' => (int) ($question['position'] ?? $index + 1),
'type' => $question['type'],
'label' => is_array($question['label'] ?? null) ? $question['label'] : [],
'settings' => is_array($question['settings'] ?? null) ? $question['settings'] : [],
'required' => (bool) ($question['required'] ?? true),
]);
}
return $poll;
}
/**
* Whether overwriting the posts with these slugs would destroy a poll's votes: deleting a post
* cascades to its poll, its votes and its ballot ledger.
*/
public static function protectsSlugs(array $slugs): bool
{
$postIds = PostTranslation::withTrashed()->whereIn('slug', $slugs)->pluck('post_id');
return PollLedgerEntry::whereIn('poll_id', Poll::withTrashed()->whereIn('post_id', $postIds)->select('id'))->exists();
}
private static function date(?string $value): ?Carbon
{
return $value ? Carbon::parse($value) : null;
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Polls;
use App\Polls\Contracts\QuestionType;
use InvalidArgumentException;
/**
* The question types enabled for this platform, read from 'polls.question_types'.
* Without that key (a platform config that has not merged the polls section yet)
* all built-in types are enabled; an empty list disables them on purpose.
*/
class QuestionTypeRegistry
{
public const DEFAULT_TYPES = [
'yes_no' => Types\YesNoType::class,
'yes_no_conditional' => Types\YesNoConditionalType::class,
'single_choice' => Types\SingleChoiceType::class,
'multi_choice' => Types\MultiChoiceType::class,
'date_time_slots' => Types\DateTimeSlotsType::class,
'scale' => Types\ScaleType::class,
'text' => Types\TextType::class,
'ranking' => Types\RankingType::class,
];
/** @var array<string, QuestionType>|null */
private ?array $types = null;
/** @return array<string, QuestionType> keyed by type key */
public function all(): array
{
if ($this->types === null) {
$this->types = [];
foreach (timebank_config('polls.question_types') ?? self::DEFAULT_TYPES as $key => $class) {
$type = app($class);
if (!$type instanceof QuestionType) {
throw new InvalidArgumentException("Poll question type [$key] must implement " . QuestionType::class);
}
if ($type->key() !== $key) {
throw new InvalidArgumentException("Poll question type [$key] reports key [{$type->key()}]");
}
$this->types[$key] = $type;
}
}
return $this->types;
}
public function has(string $key): bool
{
return isset($this->all()[$key]);
}
public function get(string $key): QuestionType
{
if (!$this->has($key)) {
throw new InvalidArgumentException("Unknown or disabled poll question type [$key]");
}
return $this->all()[$key];
}
/** @return array<string, string> key => translated label, for the editor's type select */
public function options(): array
{
return array_map(fn (QuestionType $type) => $type->label(), $this->all());
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Polls;
use App\Models\Poll;
use App\Models\PollResponse;
use Illuminate\Support\Collection;
/**
* The results of a poll as CSV rows: one row per vote, one column per question.
* Anonymous polls leave out who voted and when, and shuffle the rows.
*/
class ResultsCsv
{
public function __construct(private Poll $poll, private string $locale)
{
}
/** @return array<int, string> */
public function header(): array
{
$identity = $this->poll->anonymous ? [] : [
__('Voted'), __('Changed'), __('Profile type'), __('Profile'), __('Submitted by'),
];
return array_merge($identity, $this->questions()->map(fn ($question) => $question->labelFor($this->locale))->all());
}
/**
* Rows in chunks, so large polls are not loaded into memory at once.
*
* @return \Generator<int, array<int, string>>
*/
public function rows(): \Generator
{
$questions = $this->questions();
$query = $this->poll->responses()->with(['answers', 'voter', 'user']);
if ($this->poll->anonymous) {
// Shuffled, so the order does not reveal who voted when
foreach ($query->inRandomOrder()->get() as $response) {
yield $this->row($response, $questions);
}
return;
}
foreach ($query->orderBy('submitted_at')->orderBy('id')->lazy(200) as $response) {
yield $this->row($response, $questions);
}
}
private function row(PollResponse $response, Collection $questions): array
{
$answers = $response->answers->keyBy('poll_question_id');
$cells = [];
if (!$this->poll->anonymous) {
$changed = $response->updated_at && $response->updated_at->gt($response->submitted_at->copy()->addSecond());
$cells = [
$response->submitted_at->format('Y-m-d H:i'),
$changed ? $response->updated_at->format('Y-m-d H:i') : '',
__(class_basename($response->voter_type)),
$response->voter?->name ?? __('Deleted profile'),
$response->user?->name ?? '',
];
}
foreach ($questions as $question) {
$answer = $answers->get($question->id);
$cells[] = $answer
? $question->questionType()->answerToText($answer->value, $answer->text, $question->settings ?? [], $this->locale)
: '';
}
return array_map([self::class, 'safeCell'], $cells);
}
private function questions(): Collection
{
return $this->poll->questions()->get()->filter(
fn ($question) => app(QuestionTypeRegistry::class)->has($question->type)
)->values();
}
/**
* Prevent spreadsheet formula injection: a cell starting with = + - @ (or a tab / carriage return)
* would be run as a formula when the file is opened in Excel or LibreOffice.
*/
public static function safeCell(string $cell): string
{
return $cell !== '' && str_contains("=+-@\t\r", $cell[0]) ? "'" . $cell : $cell;
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Polls\Rules;
use Closure;
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Arr;
/**
* The field is required when the array at $listAttribute contains $key,
* e.g. the "Other" text when "Other" is one of the selected options.
*/
class RequiredWhenSelected implements ValidationRule, DataAwareRule
{
/** Run even when the field is empty. */
public bool $implicit = true;
private array $data = [];
public function __construct(private string $listAttribute, private string $key)
{
}
public function setData(array $data): static
{
$this->data = $data;
return $this;
}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$selected = (array) Arr::get($this->data, $this->listAttribute, []);
if (in_array($this->key, $selected, true) && trim((string) $value) === '') {
$fail(__('validation.required', ['attribute' => __('Other')]));
}
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace App\Polls;
use Laravel\Scout\Builder;
use Laravel\Scout\Engines\Engine;
/**
* Search engine for posts that never indexes a poll: polls are for logged-in profiles only.
*
* Scout checks shouldBeSearchable() only in its own model events and imports, while explicit
* $post->searchable() calls (Posts/Manage, the Scout observers, reindex commands) go straight
* to the engine's update(). Every one of those paths ends in Post::searchableUsing()->update(),
* so the check is made here: polls are removed from the index instead of added. Searching is
* passed through unchanged.
*/
class SearchEngineWithoutPolls extends Engine
{
public function __construct(private Engine $engine)
{
}
public function update($models)
{
$excluded = $models->reject->shouldBeSearchable();
if ($excluded->isNotEmpty()) {
$this->engine->delete($excluded->values());
}
$models = $models->filter->shouldBeSearchable()->values();
if ($models->isNotEmpty()) {
$this->engine->update($models);
}
}
public function delete($models)
{
return $this->engine->delete($models);
}
public function search(Builder $builder)
{
return $this->engine->search($builder);
}
public function paginate(Builder $builder, $perPage, $page)
{
return $this->engine->paginate($builder, $perPage, $page);
}
public function mapIds($results)
{
return $this->engine->mapIds($results);
}
public function map(Builder $builder, $results, $model)
{
return $this->engine->map($builder, $results, $model);
}
public function lazyMap(Builder $builder, $results, $model)
{
return $this->engine->lazyMap($builder, $results, $model);
}
public function getTotalCount($results)
{
return $this->engine->getTotalCount($results);
}
public function flush($model)
{
return $this->engine->flush($model);
}
public function createIndex($name, array $options = [])
{
return $this->engine->createIndex($name, $options);
}
public function deleteIndex($name)
{
return $this->engine->deleteIndex($name);
}
/** Engine-specific extras (e.g. of the Elasticsearch engine) */
public function __call($method, $parameters)
{
return $this->engine->{$method}(...$parameters);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Polls\Types;
use App\Polls\Contracts\QuestionType;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
/**
* Shared defaults: no settings, a free-text answer passed through as is,
* and partials in resources/views/polls/types/{key}/.
*/
abstract class AbstractQuestionType implements QuestionType
{
public function defaultSettings(): array
{
return [];
}
public function settingsRules(string $prefix, array $settings): array
{
return [];
}
public function newListItem(string $list): array
{
return ['key' => Str::lower(Str::random(8))];
}
public function normalize(array $answer, array $settings): array
{
return [
'value' => $answer['value'] ?? null,
'text' => $this->cleanText($answer['text'] ?? null),
];
}
public function answerToText(mixed $value, ?string $text, array $settings, ?string $locale = null): string
{
$parts = array_filter([is_scalar($value) ? (string) $value : null, $text], fn ($part) => $part !== null && $part !== '');
return implode(': ', $parts);
}
public function editorView(): string
{
return 'polls.types.' . $this->key() . '.editor';
}
public function inputView(): string
{
return 'polls.types.' . $this->key() . '.input';
}
public function resultsView(): string
{
return 'polls.types.' . $this->key() . '.results';
}
/**
* Trimmed text, or null when empty.
*/
protected function cleanText($text): ?string
{
$text = is_string($text) ? trim($text) : null;
return $text === '' ? null : $text;
}
/**
* Answers that hold a value or a text (optional questions can be left empty).
*/
protected function answered(Collection $answers): Collection
{
return $answers->filter(fn ($answer) => $answer->value !== null || $answer->text !== null)->values();
}
public function answerMessages(string $prefix, array $settings): array
{
return [];
}
protected function requiredRule(bool $required): string
{
return $required ? 'required' : 'nullable';
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Polls\Types\Concerns;
use App\Polls\Localized;
/**
* Question types with a list of options: [['key' => 'abc', 'label' => ['en' => '...']], ...]
* and an optional "Other: ..." choice.
*/
trait HasOptions
{
public const OTHER = '__other';
protected function defaultOptions(): array
{
return [$this->newListItem('options') + ['label' => []], $this->newListItem('options') + ['label' => []]];
}
protected function optionsRules(string $prefix, int $min = 2): array
{
return [
"$prefix.options" => ['required', 'array', "min:$min", 'max:30'],
"$prefix.options.*.key" => ['required', 'string', 'max:20', 'distinct', 'not_in:' . self::OTHER],
"$prefix.options.*.label" => ['required', 'array', Localized::filledRule()],
"$prefix.options.*.label.*" => ['nullable', 'string', 'max:200'],
];
}
/** @return array<int, string> */
public function optionKeys(array $settings, bool $withOther = false): array
{
$keys = array_column($settings['options'] ?? [], 'key');
if ($withOther && !empty($settings['allow_other'])) {
$keys[] = self::OTHER;
}
return $keys;
}
/** @return array<string, string> key => label in the given locale, "Other" last when allowed */
public function optionLabels(array $settings, ?string $locale = null, bool $withOther = false): array
{
$labels = [];
foreach ($settings['options'] ?? [] as $option) {
$labels[$option['key']] = Localized::pick($option['label'] ?? [], $locale);
}
if ($withOther && !empty($settings['allow_other'])) {
$labels[self::OTHER] = __('Other');
}
return $labels;
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
namespace App\Polls\Types;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
/**
* Doodle-style availability: the organizer proposes date/time slots,
* the voter marks each slot yes / if needed / no.
*/
class DateTimeSlotsType extends AbstractQuestionType
{
public const YES = 'yes';
public const IF_NEEDED = 'if_needed';
public const NO = 'no';
public function key(): string
{
return 'date_time_slots';
}
public function label(): string
{
return __('Date & time availability');
}
public function defaultSettings(): array
{
return ['slots' => [$this->newListItem('slots')], 'allow_if_needed' => true];
}
public function newListItem(string $list): array
{
return parent::newListItem($list) + ['starts_at' => null, 'ends_at' => null];
}
public function settingsRules(string $prefix, array $settings): array
{
return [
"$prefix.slots" => ['required', 'array', 'min:1', 'max:30'],
"$prefix.slots.*.key" => ['required', 'string', 'max:20', 'distinct'],
"$prefix.slots.*.starts_at" => ['required', 'date'],
"$prefix.slots.*.ends_at" => ['nullable', 'date', "after:$prefix.slots.*.starts_at"],
"$prefix.allow_if_needed" => ['boolean'],
];
}
/** @return array<string, string> availability key => label */
public function choices(array $settings): array
{
$choices = [self::YES => __('Yes')];
if (!empty($settings['allow_if_needed'])) {
$choices[self::IF_NEEDED] = __('If needed');
}
$choices[self::NO] = __('No');
return $choices;
}
/** @return array<string, string> slot key => formatted date/time, in slot order */
public function slotLabels(array $settings, ?string $locale = null): array
{
$locale = $locale ?? app()->getLocale();
$labels = [];
foreach ($settings['slots'] ?? [] as $slot) {
if (empty($slot['starts_at'])) {
continue;
}
$start = Carbon::parse($slot['starts_at'])->locale($locale);
$label = $start->isoFormat('dddd D MMMM YYYY, HH:mm');
if (!empty($slot['ends_at'])) {
$end = Carbon::parse($slot['ends_at'])->locale($locale);
$label .= ' – ' . ($end->isSameDay($start) ? $end->isoFormat('HH:mm') : $end->isoFormat('dddd D MMMM YYYY, HH:mm'));
}
$labels[$slot['key']] = $label;
}
return $labels;
}
public function answerRules(string $prefix, array $settings, bool $required): array
{
$rules = ["$prefix.value" => [$this->requiredRule($required), 'array']];
foreach (array_column($settings['slots'] ?? [], 'key') as $key) {
$rules["$prefix.value.$key"] = [$this->requiredRule($required), Rule::in(array_keys($this->choices($settings)))];
}
return $rules;
}
public function answerMessages(string $prefix, array $settings): array
{
// One message per date; the slot keys are random strings, not field names
return [
"$prefix.value.*.required" => __('Choose an answer for this date.'),
"$prefix.value.*.in" => __('Choose an answer for this date.'),
];
}
public function normalize(array $answer, array $settings): array
{
$keys = array_column($settings['slots'] ?? [], 'key');
$value = array_filter(
array_intersect_key((array) ($answer['value'] ?? []), array_flip($keys)),
fn ($availability) => $availability !== null && $availability !== ''
);
return ['value' => $value ?: null, 'text' => null];
}
public function answerToText(mixed $value, ?string $text, array $settings, ?string $locale = null): string
{
$choices = $this->choices($settings);
$parts = [];
foreach ($this->slotLabels($settings, $locale) as $key => $label) {
if (isset(((array) $value)[$key])) {
$parts[] = $label . ': ' . ($choices[$value[$key]] ?? $value[$key]);
}
}
return implode('; ', $parts);
}
public function aggregate(Collection $answers, array $settings): array
{
$answered = $this->answered($answers);
$slots = [];
foreach (array_column($settings['slots'] ?? [], 'key') as $key) {
$slots[$key] = [self::YES => 0, self::IF_NEEDED => 0, self::NO => 0];
}
foreach ($answered as $answer) {
foreach ((array) $answer->value as $key => $availability) {
if (isset($slots[$key][$availability])) {
$slots[$key][$availability]++;
}
}
}
// Best slots: most "yes", then most "if needed"; none when nobody is available
$best = [];
$top = null;
foreach ($slots as $key => $counts) {
$score = [$counts[self::YES], $counts[self::IF_NEEDED]];
if ($score[0] + $score[1] === 0) {
continue;
}
if ($top === null || $score > $top) {
$top = $score;
$best = [$key];
} elseif ($score === $top) {
$best[] = $key;
}
}
return ['total' => $answered->count(), 'slots' => $slots, 'best' => $best];
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace App\Polls\Types;
use App\Polls\Rules\RequiredWhenSelected;
use App\Polls\Types\Concerns\HasOptions;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
/**
* Pick several options (checkboxes) within min/max, optionally "Other: ...".
*/
class MultiChoiceType extends AbstractQuestionType
{
use HasOptions;
public function key(): string
{
return 'multi_choice';
}
public function label(): string
{
return __('Multi-select');
}
public function defaultSettings(): array
{
return ['options' => $this->defaultOptions(), 'allow_other' => false, 'min_selections' => null, 'max_selections' => null];
}
public function settingsRules(string $prefix, array $settings): array
{
$choiceCount = count($this->optionKeys($settings, true));
return $this->optionsRules($prefix) + [
"$prefix.allow_other" => ['boolean'],
"$prefix.min_selections" => ['nullable', 'integer', 'min:1', "max:$choiceCount"],
// Compared with the minimum only when one is set: gte fails against an empty field
"$prefix.max_selections" => array_merge(
['nullable', 'integer', 'min:1', "max:$choiceCount"],
($settings['min_selections'] ?? null) !== null ? ["gte:$prefix.min_selections"] : []
),
];
}
public function answerRules(string $prefix, array $settings, bool $required): array
{
$min = max(1, (int) ($settings['min_selections'] ?? 1));
$max = $settings['max_selections'] ?? null;
return [
"$prefix.value" => [
$this->requiredRule($required),
'array',
function (string $attribute, $value, \Closure $fail) use ($min, $max, $required) {
$count = is_array($value) ? count($value) : 0;
if ($count === 0 && !$required) {
return;
}
if ($count < $min) {
$fail(trans_choice('Select at least :count option.|Select at least :count options.', $min, ['count' => $min]));
} elseif ($max && $count > $max) {
$fail(trans_choice('Select at most :count option.|Select at most :count options.', $max, ['count' => $max]));
}
},
],
"$prefix.value.*" => ['distinct', Rule::in($this->optionKeys($settings, true))],
"$prefix.text" => [
'nullable',
'string',
'max:200',
new RequiredWhenSelected("$prefix.value", self::OTHER),
],
];
}
public function normalize(array $answer, array $settings): array
{
$value = array_values((array) ($answer['value'] ?? []));
return [
'value' => $value ?: null,
'text' => in_array(self::OTHER, $value, true) ? $this->cleanText($answer['text'] ?? null) : null,
];
}
public function answerToText(mixed $value, ?string $text, array $settings, ?string $locale = null): string
{
$labels = $this->optionLabels($settings, $locale, true);
return collect((array) $value)
->map(fn ($key) => $key === self::OTHER && $text ? $labels[$key] . ': ' . $text : ($labels[$key] ?? $key))
->implode('; ');
}
public function aggregate(Collection $answers, array $settings): array
{
$answered = $this->answered($answers);
$counts = array_fill_keys($this->optionKeys($settings, true), 0);
foreach ($answered as $answer) {
foreach (array_unique((array) $answer->value) as $key) {
if (isset($counts[$key])) {
$counts[$key]++;
}
}
}
return [
'total' => $answered->count(),
'counts' => $counts,
'other_texts' => $answered->filter(fn ($answer) => in_array(self::OTHER, (array) $answer->value, true))
->pluck('text')->filter()->values()->all(),
];
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
namespace App\Polls\Types;
use App\Polls\Types\Concerns\HasOptions;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
/**
* Put the options in order of preference, optionally only the top N.
* Results use a Borda count: with n options, 1st place gets n points, 2nd n-1, ...
*/
class RankingType extends AbstractQuestionType
{
use HasOptions;
public function key(): string
{
return 'ranking';
}
public function label(): string
{
return __('Ranking');
}
public function defaultSettings(): array
{
return ['options' => $this->defaultOptions(), 'top_n' => null];
}
public function settingsRules(string $prefix, array $settings): array
{
$optionCount = max(2, count($this->optionKeys($settings)));
return $this->optionsRules($prefix) + [
"$prefix.top_n" => ['nullable', 'integer', 'min:1', "max:$optionCount"],
];
}
/** Number of options a voter ranks. */
public function rankCount(array $settings): int
{
$optionCount = count($this->optionKeys($settings));
$topN = (int) ($settings['top_n'] ?? 0);
return $topN > 0 ? min($topN, $optionCount) : $optionCount;
}
public function answerRules(string $prefix, array $settings, bool $required): array
{
$rankCount = $this->rankCount($settings);
return [
"$prefix.value" => [
$this->requiredRule($required),
'array',
function (string $attribute, $value, \Closure $fail) use ($rankCount, $required) {
$count = is_array($value) ? count($value) : 0;
if ($count === 0 && !$required) {
return;
}
if ($count !== $rankCount) {
$fail(trans_choice('Rank :count option.|Rank :count options.', $rankCount, ['count' => $rankCount]));
}
},
],
"$prefix.value.*" => ['distinct', Rule::in($this->optionKeys($settings))],
];
}
public function normalize(array $answer, array $settings): array
{
$value = array_values((array) ($answer['value'] ?? []));
return ['value' => $value ?: null, 'text' => null];
}
public function answerToText(mixed $value, ?string $text, array $settings, ?string $locale = null): string
{
$labels = $this->optionLabels($settings, $locale);
return collect(array_values((array) $value))
->map(fn ($key, $position) => ($position + 1) . '. ' . ($labels[$key] ?? $key))
->implode('; ');
}
public function aggregate(Collection $answers, array $settings): array
{
$answered = $this->answered($answers);
$keys = $this->optionKeys($settings);
$optionCount = count($keys);
$points = array_fill_keys($keys, 0);
$firstPlace = array_fill_keys($keys, 0);
foreach ($answered as $answer) {
foreach (array_values((array) $answer->value) as $position => $key) {
if (isset($points[$key])) {
$points[$key] += $optionCount - $position;
}
}
$first = ((array) $answer->value)[0] ?? null;
if (isset($firstPlace[$first])) {
$firstPlace[$first]++;
}
}
arsort($points);
return ['total' => $answered->count(), 'points' => $points, 'first_place' => $firstPlace];
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Polls\Types;
use Illuminate\Support\Collection;
/**
* Rating on a numeric scale (e.g. 1-5) with optional end labels.
*/
class ScaleType extends AbstractQuestionType
{
public function key(): string
{
return 'scale';
}
public function label(): string
{
return __('Rating scale');
}
public function defaultSettings(): array
{
return ['min' => 1, 'max' => 5, 'min_label' => [], 'max_label' => []];
}
public function settingsRules(string $prefix, array $settings): array
{
return [
"$prefix.min" => ['required', 'integer', 'in:0,1'],
"$prefix.max" => ['required', 'integer', 'min:2', 'max:10'],
"$prefix.min_label" => ['nullable', 'array'],
"$prefix.min_label.*" => ['nullable', 'string', 'max:50'],
"$prefix.max_label" => ['nullable', 'array'],
"$prefix.max_label.*" => ['nullable', 'string', 'max:50'],
];
}
/** @return array<int, int> */
public function steps(array $settings): array
{
return range((int) ($settings['min'] ?? 1), (int) ($settings['max'] ?? 5));
}
public function answerRules(string $prefix, array $settings, bool $required): array
{
$steps = $this->steps($settings);
return [
"$prefix.value" => [$this->requiredRule($required), 'integer', 'between:' . min($steps) . ',' . max($steps)],
];
}
public function normalize(array $answer, array $settings): array
{
$value = $answer['value'] ?? null;
return ['value' => ($value === null || $value === '') ? null : (int) $value, 'text' => null];
}
public function aggregate(Collection $answers, array $settings): array
{
$answered = $this->answered($answers);
$distribution = array_fill_keys($this->steps($settings), 0);
foreach ($answered as $answer) {
if (isset($distribution[$answer->value])) {
$distribution[$answer->value]++;
}
}
return [
'total' => $answered->count(),
'average' => $answered->isEmpty() ? null : round($answered->avg('value'), 1),
'distribution' => $distribution,
];
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Polls\Types;
use App\Polls\Types\Concerns\HasOptions;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
/**
* Pick one option (radio list), optionally "Other: ...".
*/
class SingleChoiceType extends AbstractQuestionType
{
use HasOptions;
public function key(): string
{
return 'single_choice';
}
public function label(): string
{
return __('Single choice');
}
public function defaultSettings(): array
{
return ['options' => $this->defaultOptions(), 'allow_other' => false];
}
public function settingsRules(string $prefix, array $settings): array
{
return $this->optionsRules($prefix) + ["$prefix.allow_other" => ['boolean']];
}
public function answerRules(string $prefix, array $settings, bool $required): array
{
return [
"$prefix.value" => [$this->requiredRule($required), 'string', Rule::in($this->optionKeys($settings, true))],
"$prefix.text" => ['nullable', 'string', 'max:200', "required_if:$prefix.value," . self::OTHER],
];
}
public function normalize(array $answer, array $settings): array
{
$value = $answer['value'] ?? null;
return [
'value' => $value ?: null,
'text' => $value === self::OTHER ? $this->cleanText($answer['text'] ?? null) : null,
];
}
public function answerToText(mixed $value, ?string $text, array $settings, ?string $locale = null): string
{
$label = $this->optionLabels($settings, $locale, true)[$value] ?? (string) $value;
return $value === self::OTHER && $text ? "$label: $text" : $label;
}
public function aggregate(Collection $answers, array $settings): array
{
$answered = $this->answered($answers);
$counts = array_fill_keys($this->optionKeys($settings, true), 0);
foreach ($answered as $answer) {
if (isset($counts[$answer->value])) {
$counts[$answer->value]++;
}
}
return [
'total' => $answered->count(),
'counts' => $counts,
'other_texts' => $answered->where('value', self::OTHER)->pluck('text')->filter()->values()->all(),
];
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Polls\Types;
use Illuminate\Support\Collection;
/**
* Open text answer, short (one line) or long.
*/
class TextType extends AbstractQuestionType
{
public function key(): string
{
return 'text';
}
public function label(): string
{
return __('Open text');
}
public function defaultSettings(): array
{
return ['multiline' => true, 'max_length' => 1000];
}
public function settingsRules(string $prefix, array $settings): array
{
return [
"$prefix.multiline" => ['boolean'],
"$prefix.max_length" => ['required', 'integer', 'min:10', 'max:5000'],
];
}
public function answerRules(string $prefix, array $settings, bool $required): array
{
return [
"$prefix.text" => [$this->requiredRule($required), 'string', 'max:' . (int) ($settings['max_length'] ?? 1000)],
];
}
public function normalize(array $answer, array $settings): array
{
return ['value' => null, 'text' => $this->cleanText($answer['text'] ?? null)];
}
public function aggregate(Collection $answers, array $settings): array
{
$answered = $this->answered($answers);
return ['total' => $answered->count(), 'texts' => $answered->pluck('text')->all()];
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace App\Polls\Types;
use App\Polls\Localized;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
/**
* Yes / Maybe, if... (with a required condition text) / No.
*/
class YesNoConditionalType extends AbstractQuestionType
{
public const YES = 'yes';
public const MAYBE_IF = 'maybe_if';
public const NO = 'no';
public function key(): string
{
return 'yes_no_conditional';
}
public function label(): string
{
return __('Yes / No / Maybe, if...');
}
public function defaultSettings(): array
{
return ['conditional_label' => []];
}
public function settingsRules(string $prefix, array $settings): array
{
return [
"$prefix.conditional_label" => ['nullable', 'array'],
"$prefix.conditional_label.*" => ['nullable', 'string', 'max:100'],
];
}
/** @return array<string, string> answer key => label, in display order */
public function choices(array $settings, ?string $locale = null): array
{
$conditional = Localized::pick($settings['conditional_label'] ?? [], $locale);
return [
self::YES => __('Yes'),
self::MAYBE_IF => $conditional !== '' ? $conditional : __('Maybe, if...'),
self::NO => __('No'),
];
}
public function answerRules(string $prefix, array $settings, bool $required): array
{
return [
"$prefix.value" => [$this->requiredRule($required), 'string', Rule::in(array_keys($this->choices($settings)))],
"$prefix.text" => ['nullable', 'string', 'max:500', "required_if:$prefix.value," . self::MAYBE_IF],
];
}
public function answerMessages(string $prefix, array $settings): array
{
// The default message would name the stored value ("... is maybe_if")
return ["$prefix.text.required_if" => __('Please fill in the condition.')];
}
public function normalize(array $answer, array $settings): array
{
$value = $answer['value'] ?? null;
return [
'value' => $value ?: null,
// The condition only belongs to "Maybe, if..."
'text' => $value === self::MAYBE_IF ? $this->cleanText($answer['text'] ?? null) : null,
];
}
public function answerToText(mixed $value, ?string $text, array $settings, ?string $locale = null): string
{
$label = $this->choices($settings, $locale)[$value] ?? (string) $value;
return $text !== null && $text !== '' ? "$label: $text" : $label;
}
public function aggregate(Collection $answers, array $settings): array
{
$answered = $this->answered($answers);
$counts = array_fill_keys(array_keys($this->choices($settings)), 0);
foreach ($answered as $answer) {
if (isset($counts[$answer->value])) {
$counts[$answer->value]++;
}
}
return [
'total' => $answered->count(),
'counts' => $counts,
'conditions' => $answered->where('value', self::MAYBE_IF)->pluck('text')->filter()->values()->all(),
];
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Polls\Types;
/**
* Plain Yes / No, without "Maybe, if...". Reuses the yes/no/maybe-if input and results partials.
*/
class YesNoType extends YesNoConditionalType
{
public function key(): string
{
return 'yes_no';
}
public function label(): string
{
return __('Yes / No');
}
public function defaultSettings(): array
{
return [];
}
public function settingsRules(string $prefix, array $settings): array
{
return [];
}
public function choices(array $settings, ?string $locale = null): array
{
return [self::YES => __('Yes'), self::NO => __('No')];
}
public function inputView(): string
{
return 'polls.types.yes_no_conditional.input';
}
public function resultsView(): string
{
return 'polls.types.yes_no_conditional.results';
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Polls;
use App\Models\Poll;
use App\Models\PollLedgerEntry;
use App\Models\PollResponse;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
/**
* Remove the votes of a permanently deleted profile: the link to the voter is deleted
* (poll_responses + poll_answers) and the ballot is withdrawn in the ledger, so the totals
* change visibly instead of silently. The anonymous ballot itself stays in the chain.
*/
class VoteWithdrawal
{
public function __construct(private Ledger $ledger)
{
}
/**
* @return int number of withdrawn votes
*/
public function withdrawFor(Model $profile): int
{
// The profile's own votes
$responses = PollResponse::where('voter_type', get_class($profile))
->where('voter_id', $profile->getKey())
->get();
// A user's own votes on behalf of an organization / bank / admin in "one vote per linked user" polls.
// In "one vote per profile" polls the vote belongs to that profile and stays.
if ($profile instanceof User) {
$responses = $responses->merge(
PollResponse::where('user_id', $profile->getKey())
->where('voter_type', '!=', User::class)
->whereHas('poll', fn ($query) => $query->where('vote_scope', Poll::VOTE_SCOPE_PER_USER))
->get()
);
}
foreach ($responses as $response) {
DB::transaction(function () use ($response) {
if ($response->ledger_sequence) {
$this->ledger->append(
$response->poll,
PollLedgerEntry::WITHDRAW,
['reason' => 'profile_deleted'],
null,
$response->ledger_sequence
);
}
$response->delete(); // answers cascade
});
}
return $responses->count();
}
}