['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 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; } }