Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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.'));
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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')]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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()];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user