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,335 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Services\Stats\LocationFilterResolver;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Poll extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_OPEN = 'open';
|
||||
public const STATUS_CLOSED = 'closed';
|
||||
|
||||
public const VOTE_SCOPE_PER_PROFILE = 'per_profile';
|
||||
public const VOTE_SCOPE_PER_USER = 'per_user';
|
||||
|
||||
public const MEMBER_SCOPE_ALL_USERS = 'all_users';
|
||||
public const MEMBER_SCOPE_MANAGERS_COORDINATORS = 'managers_coordinators';
|
||||
|
||||
public const RESULTS_NEVER = 'never';
|
||||
public const RESULTS_AFTER_VOTE = 'after_vote';
|
||||
public const RESULTS_AFTER_CLOSE = 'after_close';
|
||||
|
||||
public const VERIFICATION_OFF = 'off';
|
||||
public const VERIFICATION_RECEIPTS = 'receipts';
|
||||
public const VERIFICATION_PUBLIC = 'public';
|
||||
|
||||
/** Profile types a poll can target, and their manager/coordinator role names. */
|
||||
public const PROFILE_TYPES = [User::class, Organization::class, Bank::class, Admin::class];
|
||||
public const MANAGER_COORDINATOR_ROLES = [
|
||||
Organization::class => ['organization-manager', 'organization-coordinator'],
|
||||
Bank::class => ['bank-manager', 'bank-coordinator'],
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'post_id', 'pollable_id', 'pollable_type',
|
||||
'status', 'opens_at', 'closes_at',
|
||||
'vote_scope', 'member_scope', 'allow_vote_change', 'anonymous', 'results_visibility',
|
||||
'verification', 'sealed_at', 'seal_hash', 'archived_at',
|
||||
'filter_by_location', 'location_country_ids', 'location_division_ids',
|
||||
'location_city_ids', 'location_district_ids',
|
||||
'target_profile_types',
|
||||
'last_login_from', 'last_login_until', 'registered_from', 'registered_until',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'archived_at' => 'datetime',
|
||||
'opens_at' => 'datetime',
|
||||
'closes_at' => 'datetime',
|
||||
'sealed_at' => 'datetime',
|
||||
'allow_vote_change' => 'boolean',
|
||||
'anonymous' => 'boolean',
|
||||
'filter_by_location' => 'boolean',
|
||||
'location_country_ids' => 'array',
|
||||
'location_division_ids' => 'array',
|
||||
'location_city_ids' => 'array',
|
||||
'location_district_ids' => 'array',
|
||||
'target_profile_types' => 'array',
|
||||
'last_login_from' => 'date',
|
||||
'last_login_until' => 'date',
|
||||
'registered_from' => 'date',
|
||||
'registered_until' => 'date',
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* Get related post for this poll
|
||||
* One-to-one relationship
|
||||
*/
|
||||
public function post()
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the owner of the poll (i.e. user, organization, bank or admin).
|
||||
*/
|
||||
public function pollable()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function questions()
|
||||
{
|
||||
return $this->hasMany(PollQuestion::class)->orderBy('position');
|
||||
}
|
||||
|
||||
public function responses()
|
||||
{
|
||||
return $this->hasMany(PollResponse::class);
|
||||
}
|
||||
|
||||
public function ledger()
|
||||
{
|
||||
return $this->hasMany(PollLedgerEntry::class)->orderBy('sequence');
|
||||
}
|
||||
|
||||
/**
|
||||
* Once someone voted, content stays locked and the poll cannot go back to draft
|
||||
* (see isContentLocked), so stored answers never point at changed options.
|
||||
*/
|
||||
public function hasResponses(): bool
|
||||
{
|
||||
return $this->responses()->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Post and questions are read-only once the poll left draft, and always once someone voted:
|
||||
* voters must all see the same poll.
|
||||
*/
|
||||
public function isContentLocked(): bool
|
||||
{
|
||||
return $this->status !== self::STATUS_DRAFT || $this->hasResponses();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open status and inside the opens_at / closes_at window.
|
||||
*/
|
||||
public function isOpen(): bool
|
||||
{
|
||||
if ($this->status !== self::STATUS_OPEN) {
|
||||
return false;
|
||||
}
|
||||
if ($this->opens_at && $this->opens_at->isFuture()) {
|
||||
return false;
|
||||
}
|
||||
if ($this->closes_at && $this->closes_at->isPast()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closed by status, or past closes_at.
|
||||
*/
|
||||
public function isClosed(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_CLOSED || ($this->closes_at && $this->closes_at->isPast());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a voter may see the results (owner and admins always can, on the admin page).
|
||||
*/
|
||||
public function resultsVisibleToVoter(bool $hasVoted): bool
|
||||
{
|
||||
return match ($this->results_visibility) {
|
||||
self::RESULTS_AFTER_VOTE => $hasVoted || $this->isClosed(),
|
||||
self::RESULTS_AFTER_CLOSE => $this->isClosed(),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Results per question, from the question types' aggregate().
|
||||
*
|
||||
* @return array<int, array> keyed by question id
|
||||
*/
|
||||
public function questionSummaries(): array
|
||||
{
|
||||
$questions = $this->questions()->get();
|
||||
$answers = PollAnswer::whereIn('poll_question_id', $questions->pluck('id'))->get()->groupBy('poll_question_id');
|
||||
|
||||
$summaries = [];
|
||||
foreach ($questions as $question) {
|
||||
$summaries[$question->id] = $question->questionType()->aggregate(
|
||||
$answers->get($question->id, collect()),
|
||||
$question->settings ?? []
|
||||
);
|
||||
}
|
||||
|
||||
return $summaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Key used by the unique index on poll_responses:
|
||||
* one vote per profile, or one vote per user behind the profile.
|
||||
*/
|
||||
public function dedupeKey(Model $profile, int $userId): string
|
||||
{
|
||||
$key = get_class($profile) . ':' . $profile->getKey();
|
||||
|
||||
// A User profile always votes for itself, so vote_scope only matters for the other profile types
|
||||
if ($this->vote_scope === self::VOTE_SCOPE_PER_USER && !$profile instanceof User) {
|
||||
$key .= ':' . $userId;
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls that are open right now (status and opens_at / closes_at window).
|
||||
*/
|
||||
public function scopeOpenNow(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_OPEN)
|
||||
->where(fn ($q) => $q->whereNull('opens_at')->orWhere('opens_at', '<=', now()))
|
||||
->where(fn ($q) => $q->whereNull('closes_at')->orWhere('closes_at', '>', now()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the active profile, voting as $user, may answer this poll right now.
|
||||
* For Organization / Bank / Admin profiles $user is the logged-in web user behind it.
|
||||
*/
|
||||
public function isTargetedAt(Model $profile, ?User $user = null): bool
|
||||
{
|
||||
return $this->isOpen()
|
||||
&& $this->matchesProfile($profile)
|
||||
&& $this->memberMayVote($profile, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the active profile administers polls: an Admin (or central bank) profile with
|
||||
* 'access polls'. Same rule as the Polls page and the results export (security audit R4).
|
||||
*/
|
||||
public static function viewerIsPollAdmin(): bool
|
||||
{
|
||||
$profile = getActiveProfile();
|
||||
|
||||
return ($profile instanceof Admin || ($profile instanceof Bank && $profile->level === 0))
|
||||
&& \Illuminate\Support\Facades\Gate::allows('access polls');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the poll is only for part of the members (any targeting, or only managers
|
||||
* and coordinators of organizations and banks). Used to warn in the mailing editor.
|
||||
*/
|
||||
public function hasLimitedAudience(): bool
|
||||
{
|
||||
$types = array_values($this->target_profile_types ?? []);
|
||||
|
||||
return ($types && count(array_intersect(self::PROFILE_TYPES, $types)) < count(self::PROFILE_TYPES))
|
||||
|| $this->filter_by_location
|
||||
|| $this->last_login_from || $this->last_login_until
|
||||
|| $this->registered_from || $this->registered_until
|
||||
|| $this->member_scope === self::MEMBER_SCOPE_MANAGERS_COORDINATORS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile type, location, last login and registration targeting.
|
||||
*/
|
||||
public function matchesProfile(Model $profile): bool
|
||||
{
|
||||
if (!$this->targetsProfileType(get_class($profile))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->applyProfileFilters($profile->newQuery()->whereKey($profile->getKey()))->exists();
|
||||
}
|
||||
|
||||
public function targetsProfileType(string $profileClass): bool
|
||||
{
|
||||
return empty($this->target_profile_types) || in_array($profileClass, $this->target_profile_types, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrain a query on one profile table to the profiles this poll targets
|
||||
* (location, last login, registration). Profile type is checked by targetsProfileType().
|
||||
*/
|
||||
public function applyProfileFilters(Builder $query): Builder
|
||||
{
|
||||
if ($this->filter_by_location) {
|
||||
LocationFilterResolver::apply(
|
||||
$query,
|
||||
$this->location_country_ids ?? [],
|
||||
$this->location_division_ids ?? [],
|
||||
$this->location_city_ids ?? [],
|
||||
$this->location_district_ids ?? []
|
||||
);
|
||||
}
|
||||
|
||||
$table = $query->getModel()->getTable();
|
||||
if ($this->last_login_from) {
|
||||
$query->whereDate("$table.last_login_at", '>=', $this->last_login_from->toDateString());
|
||||
}
|
||||
if ($this->last_login_until) {
|
||||
$query->whereDate("$table.last_login_at", '<=', $this->last_login_until->toDateString());
|
||||
}
|
||||
if ($this->registered_from) {
|
||||
$query->whereDate("$table.created_at", '>=', $this->registered_from->toDateString());
|
||||
}
|
||||
if ($this->registered_until) {
|
||||
$query->whereDate("$table.created_at", '<=', $this->registered_until->toDateString());
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* member_scope: for Organization / Bank profiles optionally only managers and coordinators
|
||||
* may vote. A User profile votes for itself; all users linked to an Admin profile may vote.
|
||||
*/
|
||||
public function memberMayVote(Model $profile, ?User $user): bool
|
||||
{
|
||||
if ($profile instanceof User) {
|
||||
return $user === null || $user->is($profile);
|
||||
}
|
||||
if ($user === null) {
|
||||
return false;
|
||||
}
|
||||
if ($this->member_scope !== self::MEMBER_SCOPE_MANAGERS_COORDINATORS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$roles = self::MANAGER_COORDINATOR_ROLES[get_class($profile)] ?? null;
|
||||
if ($roles === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$prefix = class_basename($profile) . '\\' . $profile->getKey() . '\\';
|
||||
|
||||
return $user->hasRole(array_map(fn ($role) => $prefix . $role, $roles));
|
||||
}
|
||||
|
||||
/**
|
||||
* The existing vote of this profile (or of this user behind it, for per_user polls).
|
||||
*/
|
||||
public function responseFor(Model $profile, int $userId): ?PollResponse
|
||||
{
|
||||
return $this->responses()->where('dedupe_key', $this->dedupeKey($profile, $userId))->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the voter may (still) submit: no vote yet, or changing votes is allowed.
|
||||
*/
|
||||
public function canSubmit(Model $profile, int $userId): bool
|
||||
{
|
||||
return $this->allow_vote_change || $this->responseFor($profile, $userId) === null;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user