post_type || 'publish' !== $request->post_status || '' !== $request->post_password ) { return new WP_Error( self::ERROR_NOT_FOUND, __( 'This request does not exist or is not open for voting.', 'feature-request-system' ), [ 'status' => 404 ] ); } $status = RequestTerms::term_of( $request_id, StatusTaxonomy::NAME ); if ( $status && StatusTaxonomy::is_closed( $status ) ) { return new WP_Error( self::ERROR_CLOSED, /* translators: %s: status name, such as Shipped. */ sprintf( __( 'Voting is closed because this request is %s.', 'feature-request-system' ), $status->name ), [ 'status' => 403 ] ); } $board = RequestTerms::term_of( $request_id, BoardTaxonomy::NAME ); $rule = BoardSettings::voting( $board ? $board->term_id : 0 ); if ( BoardSettings::NOBODY === $rule ) { return new WP_Error( self::ERROR_DISABLED, __( 'Voting is turned off for this board.', 'feature-request-system' ), [ 'status' => 403 ] ); } if ( BoardSettings::LOGGED_IN === $rule && ! is_user_logged_in() ) { return new WP_Error( self::ERROR_LOGIN_REQUIRED, __( 'Log in to vote on this request.', 'feature-request-system' ), [ 'status' => 401 ] ); } return true; } /** * Casts the current visitor's vote. Voting again for the same request changes nothing. * * @param int $request_id Request ID. * @return array{votes: int, voted: bool}|WP_Error The new vote count, or why the vote was refused. */ public static function cast( int $request_id ) { $allowed = self::check( $request_id ); if ( is_wp_error( $allowed ) ) { return $allowed; } $voter = VoterIdentity::key( true ); $wait = RateLimiter::attempt( 'vote', $voter ); if ( $wait ) { return self::rate_limited( $wait ); } $added = VoteRepository::add( $request_id, $voter, get_current_user_id() ); $votes = VoteCount::sync( $request_id ); // Voting again is not a new vote, so it is not announced again. if ( $added ) { do_action( self::ACTION_CAST, $request_id, get_current_user_id() ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- The constant holds the prefixed hook name. } return [ 'votes' => $votes, 'voted' => true, ]; } /** * Removes the current visitor's vote. Retracting a vote that does not exist changes nothing. * * @param int $request_id Request ID. * @return array{votes: int, voted: bool}|WP_Error The new vote count, or why the change was refused. */ public static function retract( int $request_id ) { $allowed = self::check( $request_id ); if ( is_wp_error( $allowed ) ) { return $allowed; } $voter = VoterIdentity::key(); if ( '' === $voter ) { // A guest without a voter cookie has no vote to remove. return [ 'votes' => VoteCount::get( $request_id ), 'voted' => false, ]; } $wait = RateLimiter::attempt( 'vote', $voter ); if ( $wait ) { return self::rate_limited( $wait ); } $removed = VoteRepository::remove( $request_id, $voter ); $votes = VoteCount::sync( $request_id ); if ( $removed ) { do_action( self::ACTION_RETRACTED, $request_id, get_current_user_id() ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- The constant holds the prefixed hook name. } return [ 'votes' => $votes, 'voted' => false, ]; } /** * Deletes a request's votes when the request is permanently deleted. * * @param int $post_id Deleted post ID. * @param WP_Post|null $post Deleted post. */ public static function delete_request_votes( int $post_id, $post ): void { if ( $post instanceof WP_Post && RequestPostType::NAME === $post->post_type ) { VoteRepository::remove_all( $post_id ); } } /** * Builds the error for a visitor who is over the vote limit. * * @param int $wait Seconds until the limit resets. */ private static function rate_limited( int $wait ): WP_Error { $minutes = (int) ceil( $wait / MINUTE_IN_SECONDS ); return new WP_Error( self::ERROR_RATE_LIMITED, sprintf( /* translators: %d: number of minutes. */ _n( 'Too many votes in a short time. Try again in %d minute.', 'Too many votes in a short time. Try again in %d minutes.', $minutes, 'feature-request-system' ), $minutes ), [ 'status' => 429, 'retry_after' => $wait, ] ); } }