Files

336 lines
11 KiB
PHP

<?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;
}
}