Add poll system for Patrons Circle

This commit is contained in:
Alex Cabal 2022-06-29 16:51:45 -05:00
parent 3555d53615
commit 2ef5ce6551
44 changed files with 717 additions and 98 deletions

View file

@ -258,6 +258,16 @@ Define webroot /standardebooks.org/web
# Newsletter
RewriteRule ^/newsletter$ /newsletter/subscribers/new.php
RewriteRule ^/newsletter/subscribers/([^\./]+?)/(delete|confirm)$ /newsletter/subscribers/$2.php?uuid=$1
# Polls
RewriteRule ^/patrons-circle/polls/([^/]+)$ /patrons-circle/polls/get.php?pollurlname=$1
RewriteRule ^/patrons-circle/polls/([^/]+)/votes/(new|success)$ /patrons-circle/polls/votes/$2.php?pollurlname=$1
RewriteCond expr "tolower(%{REQUEST_METHOD}) =~ /^get$/"
RewriteRule ^/patrons-circle/polls/([^/]+)/votes$ /patrons-circle/polls/votes/index.php?pollurlname=$1
RewriteCond expr "tolower(%{REQUEST_METHOD}) =~ /^post$/"
RewriteRule ^/patrons-circle/polls/([^/]+)/votes$ /patrons-circle/polls/votes/post.php?pollurlname=$1
</VirtualHost>
<VirtualHost *:80>

View file

@ -257,4 +257,14 @@ Define webroot /standardebooks.org/web
# Newsletter
RewriteRule ^/newsletter$ /newsletter/subscribers/new.php
RewriteRule ^/newsletter/subscribers/([^\./]+?)/(delete|confirm)$ /newsletter/subscribers/$2.php?uuid=$1
# Polls
RewriteRule ^/patrons-circle/polls/([^/]+)$ /patrons-circle/polls/get.php?pollurlname=$1
RewriteRule ^/patrons-circle/polls/([^/]+)/votes/(new|success)$ /patrons-circle/polls/votes/$2.php?pollurlname=$1
RewriteCond expr "tolower(%{REQUEST_METHOD}) =~ /^get$/"
RewriteRule ^/patrons-circle/polls/([^/]+)/votes$ /patrons-circle/polls/votes/index.php?pollurlname=$1
RewriteCond expr "tolower(%{REQUEST_METHOD}) =~ /^post$/"
RewriteRule ^/patrons-circle/polls/([^/]+)/votes$ /patrons-circle/polls/votes/post.php?pollurlname=$1
</VirtualHost>

View file

@ -19,6 +19,12 @@ parameters:
- '#Method Ebook::NullIfEmpty\(\) has parameter \$elements with no type specified.#'
- '#Method HttpInput::GetHttpVar\(\) has no return type specified.#'
- '#Method HttpInput::GetHttpVar\(\) has parameter \$default with no type specified.#'
# Ignore errors caused by access to our PropertiesBase pattern
- '#Access to protected property .+#'
# Ignore symbols that PHPStan can't find
- '#Constant EMAIL_SMTP_USERNAME not found.#'
level:
7
paths:

View file

@ -0,0 +1,8 @@
CREATE TABLE `PollItems` (
`PollItemId` int(10) unsigned NOT NULL AUTO_INCREMENT,
`PollId` int(10) unsigned NOT NULL,
`Name` varchar(255) NOT NULL,
`Description` text DEFAULT NULL,
`SortOrder` tinyint(3) unsigned NOT NULL,
PRIMARY KEY (`PollItemId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

11
config/sql/se/Polls.sql Normal file
View file

@ -0,0 +1,11 @@
CREATE TABLE `Polls` (
`PollId` int(11) unsigned NOT NULL AUTO_INCREMENT,
`Created` datetime NOT NULL,
`Name` varchar(255) NOT NULL,
`UrlName` varchar(255) NOT NULL,
`Description` text DEFAULT NULL,
`Start` datetime DEFAULT NULL,
`End` datetime DEFAULT NULL,
PRIMARY KEY (`PollId`),
UNIQUE KEY `idxUnique` (`UrlName`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

6
config/sql/se/Votes.sql Normal file
View file

@ -0,0 +1,6 @@
CREATE TABLE `Votes` (
`UserId` int(11) unsigned NOT NULL,
`PollItemId` int(11) unsigned NOT NULL,
`Created` datetime NOT NULL,
UNIQUE KEY `idxUnique` (`PollItemId`,`UserId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View file

@ -59,6 +59,13 @@ const HTTP_VAR_BOOL = 2;
const HTTP_VAR_DEC = 3;
const HTTP_VAR_ARRAY = 4;
const HTTP_GET = 0;
const HTTP_POST = 1;
const HTTP_PATCH = 2;
const HTTP_PUT = 3;
const HTTP_DELETE = 4;
const HTTP_HEAD = 5;
const VIEW_GRID = 'grid';
const VIEW_LIST = 'list';

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class InvalidPatronException extends SeException{
protected $message = 'We couldnt locate you in the Patrons Circle.';
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class InvalidPollException extends SeException{
protected $message = 'We couldnt locate that poll.';
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class InvalidPollItemException extends SeException{
protected $message = 'We couldnt locate that poll item.';
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class InvalidUserException extends SeException{
protected $message = 'We couldnt locate you in our system.';
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class PollClosedException extends SeException{
protected $message = 'This poll is not open to voting right now.';
}

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class PollItemRequiredException extends SeException{
protected $message = 'You must select an item to vote on.';
}

View file

@ -8,6 +8,12 @@ class ValidationException extends SeException{
public $HasExceptions = false;
public $IsFatal = false;
public function __construct(?\Exception $exception = null){
if($exception !== null){
$this->Add($exception);
}
}
public function __toString(): string{
$output = '';
foreach($this->Exceptions as $exception){

View file

@ -0,0 +1,6 @@
<?
namespace Exceptions;
class VoteExistsException extends SeException{
protected $message = 'Youve already voted in this poll.';
}

View file

@ -1,4 +1,5 @@
<?
use Safe\DateTime;
use function Safe\file_get_contents;
use function Safe\file_put_contents;
use function Safe\tempnam;

View file

@ -1,5 +1,30 @@
<?
use function Safe\preg_match;
class HttpInput{
public static function RequestMethod(): int{
$method = $_POST['_method'] ?? $_SERVER['REQUEST_METHOD'];
switch($method){
case 'POST':
return HTTP_POST;
case 'PUT':
return HTTP_PUT;
case 'DELETE':
return HTTP_DELETE;
case 'PATCH':
return HTTP_PATCH;
case 'HEAD':
return HTTP_HEAD;
}
return HTTP_GET;
}
public static function RequestType(): int{
return preg_match('/\btext\/html\b/ius', $_SERVER['HTTP_ACCEPT']) ? WEB : REST;
}
public static function Str(string $type, string $variable, bool $allowEmptyString = true, string $default = null): ?string{
$var = self::GetHttpVar($variable, HTTP_VAR_STR, $type, $default);

View file

@ -1,4 +1,5 @@
<?
use Safe\DateTime;
use Ramsey\Uuid\Uuid;
class NewsletterSubscriber extends PropertiesBase{
@ -26,9 +27,10 @@ class NewsletterSubscriber extends PropertiesBase{
$uuid = Uuid::uuid4();
$this->Uuid = $uuid->toString();
$this->Timestamp = new DateTime();
try{
Db::Query('insert into NewsletterSubscribers (Email, Uuid, FirstName, LastName, IsConfirmed, IsSubscribedToNewsletter, IsSubscribedToSummary, Timestamp) values (?, ?, ?, ?, ?, ?, ?, utc_timestamp());', [$this->Email, $this->Uuid, $this->FirstName, $this->LastName, false, $this->IsSubscribedToNewsletter, $this->IsSubscribedToSummary]);
Db::Query('INSERT into NewsletterSubscribers (Email, Uuid, FirstName, LastName, IsConfirmed, IsSubscribedToNewsletter, IsSubscribedToSummary, Timestamp) values (?, ?, ?, ?, ?, ?, ?, ?);', [$this->Email, $this->Uuid, $this->FirstName, $this->LastName, false, $this->IsSubscribedToNewsletter, $this->IsSubscribedToSummary, $this->Timestamp]);
}
catch(PDOException $ex){
if($ex->errorInfo[1] == 1062){
@ -53,11 +55,11 @@ class NewsletterSubscriber extends PropertiesBase{
}
public function Confirm(): void{
Db::Query('update NewsletterSubscribers set IsConfirmed = true where NewsletterSubscriberId = ?;', [$this->NewsletterSubscriberId]);
Db::Query('UPDATE NewsletterSubscribers set IsConfirmed = true where NewsletterSubscriberId = ?;', [$this->NewsletterSubscriberId]);
}
public function Delete(): void{
Db::Query('delete from NewsletterSubscribers where NewsletterSubscriberId = ?;', [$this->NewsletterSubscriberId]);
Db::Query('DELETE from NewsletterSubscribers where NewsletterSubscriberId = ?;', [$this->NewsletterSubscriberId]);
}
public function Validate(): void{
@ -77,11 +79,7 @@ class NewsletterSubscriber extends PropertiesBase{
}
public static function Get(string $uuid): NewsletterSubscriber{
if($uuid == ''){
throw new Exceptions\InvalidNewsletterSubscriberException();
}
$subscribers = Db::Query('select * from NewsletterSubscribers where Uuid = ?;', [$uuid], 'NewsletterSubscriber');
$subscribers = Db::Query('SELECT * from NewsletterSubscribers where Uuid = ?;', [$uuid], 'NewsletterSubscriber');
if(sizeof($subscribers) == 0){
throw new Exceptions\InvalidNewsletterSubscriberException();

View file

@ -1,4 +1,5 @@
<?
use Safe\DateTime;
use function Safe\file_put_contents;
class OpdsFeed extends AtomFeed{

View file

@ -1,27 +0,0 @@
<?
use Safe\DateTime;
use function Safe\substr;
abstract class OrmBase{
final public function __construct(){
// Satisfy PHPStan and prevent child classes from having their own constructor
}
public static function FillObject(Object $object, array $row): Object{
foreach($row as $property => $value){
if(substr($property, strlen($property) - 5) == 'Cache'){
$property = substr($property, 0, strlen($property) - 5);
$object->$property = $value;
}
else{
$object->$property = $value;
}
}
return $object;
}
public static function FromRow(array $row): Object{
return self::FillObject(new static(), $row);
}
}

View file

@ -10,22 +10,30 @@ class Patron extends PropertiesBase{
public $Timestamp = null;
public $DeactivatedTimestamp = null;
public static function Get(int $userId): ?Patron{
$result = Db::Query('select * from Patrons where UserId = ?', [$userId], 'Patron');
public static function Get(?int $userId): Patron{
$result = Db::Query('SELECT * from Patrons where UserId = ?', [$userId], 'Patron');
return $result[0] ?? null;
if(sizeof($result) == 0){
throw new Exceptions\InvalidPatronException();
}
protected function GetUser(): ?User{
if($this->User === null && $this->UserId !== null){
$this->User = User::Get($this->UserId);
return $result[0];
}
return $this->User;
public static function GetByEmail(?string $email): Patron{
$result = Db::Query('SELECT p.* from Patrons p inner join Users u on p.UserId = u.UserId where u.Email = ?', [$email], 'Patron');
if(sizeof($result) == 0){
throw new Exceptions\InvalidPatronException();
}
return $result[0];
}
public function Create(bool $sendEmail = true): void{
Db::Query('insert into Patrons (Timestamp, UserId, IsAnonymous, AlternateName, IsSubscribedToEmail) values(utc_timestamp(), ?, ?, ?, ?);', [$this->UserId, $this->IsAnonymous, $this->AlternateName, $this->IsSubscribedToEmail]);
$this->Timestamp = new DateTime();
Db::Query('INSERT into Patrons (Timestamp, UserId, IsAnonymous, AlternateName, IsSubscribedToEmail) values(?, ?, ?, ?, ?);', [$this->Timestamp, $this->UserId, $this->IsAnonymous, $this->AlternateName, $this->IsSubscribedToEmail]);
if($sendEmail){
$this->SendWelcomeEmail();
@ -33,7 +41,7 @@ class Patron extends PropertiesBase{
}
public function Reactivate(bool $sendEmail = true): void{
Db::Query('update Patrons set Timestamp = utc_timestamp(), DeactivatedTimestamp = null, IsAnonymous = ?, IsSubscribedToEmail = ?, AlternateName = ? where UserId = ?;', [$this->IsAnonymous, $this->IsSubscribedToEmail, $this->AlternateName, $this->UserId]);
Db::Query('UPDATE Patrons set Timestamp = utc_timestamp(), DeactivatedTimestamp = null, IsAnonymous = ?, IsSubscribedToEmail = ?, AlternateName = ? where UserId = ?;', [$this->IsAnonymous, $this->IsSubscribedToEmail, $this->AlternateName, $this->UserId]);
$this->Timestamp = new DateTime();
$this->DeactivatedTimestamp = null;
@ -43,7 +51,7 @@ class Patron extends PropertiesBase{
}
private function SendWelcomeEmail(): void{
$this->GetUser();
$this->__get('User');
if($this->User !== null){
$em = new Email();
$em->To = $this->User->Email;

View file

@ -11,21 +11,13 @@ class Payment extends PropertiesBase{
public $Fee;
public $IsRecurring;
protected function GetUser(): ?User{
if($this->User === null && $this->UserId !== null){
$this->User = User::Get($this->UserId);
}
return $this->User;
}
public function Create(): void{
if($this->UserId === null){
// Check if we have to create a new user in the database
// If the User object isn't null, then check if we already have this user in our system
if($this->User !== null && $this->User->Email !== null){
$result = Db::Query('select * from Users where Email = ?', [$this->User->Email], 'User');
$result = Db::Query('SELECT * from Users where Email = ?', [$this->User->Email], 'User');
if(sizeof($result) == 0){
// User doesn't exist, create it now
@ -41,7 +33,7 @@ class Payment extends PropertiesBase{
}
try{
Db::Query('insert into Payments (UserId, Timestamp, ChannelId, TransactionId, Amount, Fee, IsRecurring) values(?, ?, ?, ?, ?, ?, ?);', [$this->UserId, $this->Timestamp, $this->ChannelId, $this->TransactionId, $this->Amount, $this->Fee, $this->IsRecurring]);
Db::Query('INSERT into Payments (UserId, Timestamp, ChannelId, TransactionId, Amount, Fee, IsRecurring) values(?, ?, ?, ?, ?, ?, ?);', [$this->UserId, $this->Timestamp, $this->ChannelId, $this->TransactionId, $this->Amount, $this->Fee, $this->IsRecurring]);
}
catch(PDOException $ex){
if($ex->errorInfo[1] == 1062){

88
lib/Poll.php Normal file
View file

@ -0,0 +1,88 @@
<?
use Safe\DateTime;
use function Safe\usort;
class Poll extends PropertiesBase{
public $PollId;
public $Name;
public $UrlName;
public $Description;
public $Created;
public $Start;
public $End;
protected $Url = null;
protected $PollItems = null;
protected $PollItemsByWinner = null;
protected $VoteCount = null;
protected function GetUrl(): string{
if($this->Url === null){
$this->Url = '/patrons-circle/polls/' . $this->UrlName;
}
return $this->Url;
}
protected function GetVoteCount(): int{
if($this->VoteCount === null){
$this->VoteCount = (Db::Query('select count(*) as VoteCount from Votes v inner join PollItems pi on v.PollItemId = pi.PollItemId where pi.PollId = ?', [$this->PollId]))[0]->VoteCount;
}
return $this->VoteCount;
}
/**
* @return array<PollItem>
*/
protected function GetPollItems(): array{
if($this->PollItems === null){
$this->PollItems = Db::Query('SELECT * from PollItems where PollId = ? order by SortOrder asc', [$this->PollId], 'PollItem');
}
return $this->PollItems;
}
/**
* @return array<PollItem>
*/
protected function GetPollItemsByWinner(): array{
if($this->PollItemsByWinner === null){
$this->__get('PollItems');
$this->PollItemsByWinner = $this->PollItems;
usort($this->PollItemsByWinner, function(PollItem $a, PollItem $b){ return $a->VoteCount <=> $b->VoteCount; });
$this->PollItemsByWinner = array_reverse($this->PollItemsByWinner);
}
return $this->PollItemsByWinner;
}
public function IsActive(): bool{
$now = new DateTime();
if( ($this->Start !== null && $this->Start > $now) || ($this->End !== null && $this->End < $now)){
return false;
}
return true;
}
public static function Get(?int $pollId): Poll{
$result = Db::Query('SELECT * from Polls where PollId = ?', [$pollId], 'Poll');
if(sizeof($result) == 0){
throw new Exceptions\InvalidPollException();
}
return $result[0];
}
public static function GetByUrlName(?string $urlName): Poll{
$result = Db::Query('SELECT * from Polls where UrlName = ?', [$urlName], 'Poll');
if(sizeof($result) == 0){
throw new Exceptions\InvalidPollException();
}
return $result[0];
}
}

27
lib/PollItem.php Normal file
View file

@ -0,0 +1,27 @@
<?
class PollItem extends PropertiesBase{
public $PollItemId;
public $PollId;
public $Name;
public $Description;
protected $VoteCount = null;
protected $Poll = null;
protected function GetVoteCount(): int{
if($this->VoteCount === null){
$this->VoteCount = (Db::Query('select count(*) as VoteCount from Votes v inner join PollItems pi on v.PollItemId = pi.PollItemId where pi.PollItemId = ?', [$this->PollItemId]))[0]->VoteCount;
}
return $this->VoteCount;
}
public static function Get(?int $pollItemId): PollItem{
$result = Db::Query('SELECT * from PollItems where PollItemId = ?', [$pollItemId], 'PollItem');
if(sizeof($result) == 0){
throw new Exceptions\InvalidPollItemException();
}
return $result[0];
}
}

View file

@ -1,7 +1,7 @@
<?
use function Safe\substr;
abstract class PropertiesBase extends OrmBase{
abstract class PropertiesBase{
/**
* @param mixed $var
* @return mixed
@ -12,6 +12,15 @@ abstract class PropertiesBase extends OrmBase{
if(method_exists($this, $function)){
return $this->$function();
}
elseif(property_exists($this, $var . 'Id') && method_exists($var, 'Get')){
// If our object has an VarId attribute, and the Var class also has a ::Get method,
// call it and return the result
if($this->$var === null && $this->{$var . 'Id'} !== null){
$this->$var = $var::Get($this->{$var . 'Id'});
}
return $this->$var;
}
elseif(substr($var, 0, 7) == 'Display'){
// If we're asked for a DisplayXXX property and the getter doesn't exist, format as escaped HTML.
if($this->$var === null){

View file

@ -1,4 +1,9 @@
<?
use Safe\DateTime;
use function Safe\file_get_contents;
use function Safe\filesize;
use function Safe\preg_replace;
class RssFeed extends Feed{
public $Description;

View file

@ -1,5 +1,6 @@
<?
use Ramsey\Uuid\Uuid;
use Safe\DateTime;
class User extends PropertiesBase{
public $UserId;
@ -14,10 +15,24 @@ class User extends PropertiesBase{
public $Timestamp;
public $Uuid;
public static function Get(int $userId): ?User{
$result = Db::Query('select * from Users where UserId = ?', [$userId], 'User');
public static function Get(?int $userId): User{
$result = Db::Query('SELECT * from Users where UserId = ?', [$userId], 'User');
return $result[0] ?? null;
if(sizeof($result) == 0){
throw new Exceptions\InvalidUserException();
}
return $result[0];
}
public static function GetByEmail(?string $email): User{
$result = Db::Query('SELECT * from Users where Email = ?', [$email], 'User');
if(sizeof($result) == 0){
throw new Exceptions\InvalidUserException();
}
return $result[0];
}
protected function GetName(): string{
@ -31,9 +46,10 @@ class User extends PropertiesBase{
public function Create(): void{
$uuid = Uuid::uuid4();
$this->Uuid = $uuid->toString();
$this->Timestamp = new DateTime();
try{
Db::Query('insert into Users (Email, Name, Uuid, Timestamp) values (?, ?, ?, utc_timestamp());', [$this->Email, $this->Name, $this->Uuid]);
Db::Query('INSERT into Users (Email, Name, Uuid, Timestamp) values (?, ?, ?, ?);', [$this->Email, $this->Name, $this->Uuid, $this->Timestamp]);
}
catch(PDOException $ex){
if($ex->errorInfo[1] == 1062){

87
lib/Vote.php Normal file
View file

@ -0,0 +1,87 @@
<?
use Safe\DateTime;
class Vote extends PropertiesBase{
public $VoteId;
public $UserId;
protected $User = null;
public $Created;
public $PollItemId;
protected $PollItem = null;
protected $Url = null;
protected function GetUrl(): string{
if($this->Url === null){
$this->Url = '/patrons-circle/polls/' . $this->PollItem->Poll->Url . '/votes/' . $this->UserId;
}
return $this->Url;
}
protected function Validate(): void{
$error = new Exceptions\ValidationException();
if($this->UserId === null){
$error->Add(new Exceptions\InvalidPatronException());
}
if($this->PollItemId === null){
$error->Add(new Exceptions\PollItemRequiredException());
}
else{
$this->__get('PollItem');
if($this->PollItem === null){
$error->Add(new Exceptions\InvalidPollException());
}
}
if(!$this->PollItem->Poll->IsActive()){
$error->Add(new Exceptions\PollClosedException());
}
if(!$error->HasExceptions){
// Basic sanity checks done, now check if we've already voted
// in this poll
$this->__get('User');
if($this->User === null){
$error->Add(new Exceptions\InvalidPatronException());
}
else{
// Do we already have a vote for this poll, from this user?
if( (Db::Query('
SELECT count(*) as VoteCount from Votes v inner join
(select PollItemId from PollItems pi inner join Polls p on pi.PollId = p.PollId) x
on v.PollItemId = x.PollItemId where v.UserId = ?', [$this->UserId]))[0]->VoteCount > 0){
$error->Add(new Exceptions\VoteExistsException());
}
}
}
if($error->HasExceptions){
throw $error;
}
}
public function Create(?string $email = null): void{
if($email !== null){
try{
$patron = Patron::GetByEmail($email);
$this->UserId = $patron->UserId;
$this->User = $patron->User;
}
catch(Exceptions\InvalidPatronException $ex){
// Can't validate patron email - do nothing for now,
// this will be caught later when we validate the vote during creation.
// Save the email in the User object in case we want it later,
// for example prefilling the 'create' form after an error is returned.
$this->User = new User();
$this->User->Email = $email;
}
}
$this->Validate();
$this->Created = new DateTime();
Db::Query('INSERT into Votes (UserId, PollItemId, Created) values (?, ?, ?)', [$this->UserId, $this->PollItemId, $this->Created]);
}
}

View file

@ -3,5 +3,5 @@
require_once('/standardebooks.org/web/lib/Core.php');
// Delete unconfirmed newsletter subscribers who are more than a week old
Db::Query('delete from NewsletterSubscribers where IsConfirmed = false and datediff(utc_timestamp(), Timestamp) >= 7');
Db::Query('DELETE from NewsletterSubscribers where IsConfirmed = false and datediff(utc_timestamp(), Timestamp) >= 7');
?>

View file

@ -160,9 +160,10 @@ try{
if(($payment->IsRecurring && $payment->Amount >= 10 && $payment->Timestamp >= $lastMonth) || ($payment->Amount >= 100 && $payment->Timestamp >= $lastYear)){
// This payment is eligible for the Patrons Circle.
// Are we already a patron?
try{
$patron = Patron::Get($payment->UserId);
if($patron === null){
}
catch(Exceptions\InvalidPatronException $ex){
// Not a patron yet, add them to the Patrons Circle
$patron = new Patron();
$patron->UserId = $payment->UserId;
@ -194,7 +195,7 @@ try{
else{
// Not a patron; send a thank you email anyway, but only if this is a non-recurring donation,
// or if it's their very first recurring donation
$previousPaymentCount = (Db::Query('select count(*) as PreviousPaymentCount from Payments where UserId = ? and IsRecurring = true', [$payment->UserId]))[0]->PreviousPaymentCount;
$previousPaymentCount = (Db::Query('SELECT count(*) as PreviousPaymentCount from Payments where UserId = ? and IsRecurring = true', [$payment->UserId]))[0]->PreviousPaymentCount;
// We just added a payment to the system, so if this is their very first recurring payment, we expect the count to be exactly 1
if(!$payment->IsRecurring || $previousPaymentCount == 1){
@ -210,7 +211,7 @@ try{
}
}
Db::Query('delete from PendingPayments where TransactionId = ?;', [$pendingPayment->TransactionId]);
Db::Query('DELETE from PendingPayments where TransactionId = ?;', [$pendingPayment->TransactionId]);
$log->Write('Donation processed.');
}

View file

@ -9,7 +9,7 @@ $startDate = new DateTime('2022-07-01');
$endDate = new DateTime('2022-07-31');
$autoHide = $autoHide ?? true;
$showDonateButton = $showDonateButton ?? true;
$current = (Db::Query('select count(*) as PatronCount from Patrons where Timestamp >= ?', [$startDate]))[0]->PatronCount;
$current = (Db::Query('SELECT count(*) as PatronCount from Patrons where Timestamp >= ?', [$startDate]))[0]->PatronCount;
$target = 70;
$stretchCurrent = 0;
$stretchTarget = 20;

View file

@ -7,7 +7,7 @@ $anonymousPatronCount = 0;
// Get the Patrons Circle and try to sort by last name ascending
// See <https://mariadb.com/kb/en/pcre/#unicode-character-properties> for Unicode character properties
$patronsCircle = Db::Query('select if(p.AlternateName is not null, p.AlternateName, u.Name) as SortedName
$patronsCircle = Db::Query('SELECT if(p.AlternateName is not null, p.AlternateName, u.Name) as SortedName
from Patrons p inner join Users u
on p.UserId = u.UserId
where
@ -16,7 +16,7 @@ $patronsCircle = Db::Query('select if(p.AlternateName is not null, p.AlternateNa
order by regexp_substr(SortedName, "[\\\p{Lu}][\\\p{L}\-]+$") asc;
');
$anonymousPatronCount = Db::Query('select sum(cnt) as AnonymousPatronCount
$anonymousPatronCount = Db::Query('SELECT sum(cnt) as AnonymousPatronCount
from
(
(

View file

@ -1836,6 +1836,16 @@ main.ebooks nav ol li.highlighted:nth-last-child(2)::after{
grid-template-columns: 1fr;
}
.button-row.narrow{
display: flex;
justify-content: center;
flex-wrap: wrap;
}
.button-row:last-child{
margin-bottom: 0;
}
.masthead h2 + section > h3{
margin-top: 0;
}
@ -2047,14 +2057,16 @@ article.ebook h2 + section > h3:first-of-type{
left: -5000px;
}
form[action*="/polls/"],
form[action="/newsletter/subscribers"]{
display: grid;
grid-gap: 1rem;
grid-gap: 2rem;
grid-template-columns: 1fr 1fr;
margin-top: 1rem;
margin-bottom: 0;
}
form[action*="/polls/"] label.email,
form[action="/newsletter/subscribers"] label.email,
form[action="/newsletter/subscribers"] label.captcha{
grid-column: 1 / span 2;
@ -2070,18 +2082,19 @@ form[action="/newsletter/subscribers"] label.captcha div input{
align-self: center;
}
form[action="/newsletter/subscribers"] ul{
form fieldset ul{
list-style: none;
}
form[action*="/polls/"] button,
form[action="/newsletter/subscribers"] button{
grid-column: 2;
justify-self: end;
margin-left: 0;
}
form[action*="/polls/"] fieldset,
form[action="/newsletter/subscribers"] fieldset{
margin-top: 1rem;
grid-column: 1 / span 2;
}
@ -2096,15 +2109,25 @@ fieldset p{
label.checkbox{
display: inline-flex;
align-items: center;
align-items: flex-start;
text-align: left;
line-height: 1;
cursor: pointer;
}
label.checkbox input{
margin-right: .25rem;
}
label.checkbox span{
display: block;
}
label.checkbox span > span{
line-height: 1.6;
margin-top: .25rem;
}
article.step-by-step-guide ol ol{
margin-left: 1.2rem;
list-style: decimal;
@ -2130,19 +2153,12 @@ aside header{
font-size: 1.5rem;
}
.meter,
.progress{
position: relative;
font-size: 0;
}
.progress > div{
position: absolute;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
}
.donation a.button{
display: inline-block;
white-space: normal;
@ -2215,6 +2231,7 @@ aside header{
hyphens: auto;
}
.meter p,
.progress p{
font-size: 1rem;
font-family: "League Spartan", Arial, sans-serif;
@ -2251,7 +2268,14 @@ aside header{
font-size: .75rem;
}
.meter > div,
.progress > div{
position: absolute;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
/* Animate the div instead of the bar itself, because animating the bar triggers an
FF bug that causes infinite requsts to stripes.svg */
background: url("/images/stripes.svg") transparent;
@ -2260,6 +2284,7 @@ aside header{
z-index: 3;
}
meter,
progress{
-webkit-appearance: none;
appearance: none;
@ -2280,6 +2305,7 @@ progress::-webkit-progress-value{
box-shadow: 1px 0 1px rgba(0, 0, 0, .25);
}
meter::-moz-meter-bar,
progress::-moz-progress-bar{
background: var(--button);
box-shadow: 1px 0 1px rgba(0, 0, 0, .25);
@ -2507,6 +2533,33 @@ ul.feed p{
word-break: break-word;
}
.center-notice{
text-align: center;
font-style: italic;
}
.votes{
margin-top: 2rem;
}
.votes td:first-child{
font-weight: bold;
}
.votes td{
width: 50%;
padding: 1rem;
text-align: left;
}
.votes tr:first-child td{
padding-top: 0;
}
.votes tr:last-child td{
padding-bottom: 0;
}
@media (hover: none) and (pointer: coarse){ /* target ipads and smartphones without a mouse */
/* For iPad, unset the height so it matches the other elements */
select[multiple]{
@ -3016,6 +3069,17 @@ ul.feed p{
form[action="/settings"] select{
width: 100%;
}
.votes tr,
.votes tr td{
display: block;
width: 100%;
padding: 0;
}
.votes tr + tr{
margin-top: 2rem;
}
}
@media(max-width: 470px){

View file

@ -3,11 +3,11 @@ require_once('Core.php');
use function Safe\preg_match;
$requestType = preg_match('/\btext\/html\b/ius', $_SERVER['HTTP_ACCEPT']) ? WEB : REST;
$requestType = HttpInput::RequestType();
try{
// We may use GET if we're called from an unsubscribe link in an email
if(!in_array($_SERVER['REQUEST_METHOD'], ['DELETE', 'GET'])){
if(!in_array(HttpInput::RequestMethod(), [HTTP_DELETE, HTTP_GET])){
throw new Exceptions\InvalidRequestException();
}

View file

@ -4,14 +4,14 @@ require_once('Core.php');
use function Safe\preg_match;
use function Safe\session_unset;
if($_SERVER['REQUEST_METHOD'] != 'POST'){
if(HttpInput::RequestMethod() != HTTP_POST){
http_response_code(405);
exit();
}
session_start();
$requestType = preg_match('/\btext\/html\b/ius', $_SERVER['HTTP_ACCEPT']) ? WEB : REST;
$requestType = HttpInput::RequestType();
$subscriber = new NewsletterSubscriber();
@ -39,11 +39,7 @@ try{
$captcha = $_SESSION['captcha'] ?? null;
if($captcha === null || mb_strtolower($captcha) !== mb_strtolower(HttpInput::Str(POST, 'captcha', false))){
$error = new Exceptions\ValidationException();
$error->Add(new Exceptions\InvalidCaptchaException());
throw $error;
throw new Exceptions\ValidationException(new Exceptions\InvalidCaptchaException());
}
$subscriber->Create();

View file

@ -0,0 +1,3 @@
<?
header('Location: /donate#patrons-circle');
exit();

View file

@ -0,0 +1,40 @@
<?
require_once('Core.php');
use Safe\DateTime;
$poll = null;
try{
$poll = Poll::GetByUrlName(HttpInput::Str(GET, 'pollurlname', false));
}
catch(Exceptions\SeException $ex){
http_response_code(404);
include(WEB_ROOT . '/404.php');
exit();
}
?><?= Template::Header(['title' => $poll->Name, 'highlight' => '', 'description' => $poll->Description]) ?>
<main>
<article>
<h1><?= Formatter::ToPlainText($poll->Name) ?></h1>
<p><?= $poll->Description ?></p>
<? if($poll->IsActive()){ ?>
<? if($poll->End !== null){ ?>
<p class="center-notice">This poll closes on <?= $poll->End->format('F j, Y g:i A') ?>.</p>
<? } ?>
<p class="button-row narrow">
<a href="<?= $poll->Url ?>/votes/new" class="button">Vote now</a>
<a href="<?= $poll->Url ?>/votes" class="button">View results</a>
</p>
<? }else{ ?>
<? if($poll->Start !== null && $poll->Start > new DateTime()){ ?>
<p class="center-notice">This poll opens on <?= $poll->Start->format('F j, Y g:i A') ?>.</p>
<? }else{ ?>
<p class="center-notice">This poll closed on <?= $poll->End->format('F j, Y g:i A') ?>.</p>
<p class="button-row narrow"><a href="<?= $poll->Url ?>/votes" class="button">View results</a></p>
<? } ?>
<? } ?>
</article>
</main>
<?= Template::Footer() ?>

View file

@ -0,0 +1,46 @@
<?
require_once('Core.php');
$poll = null;
try{
$poll = Poll::GetByUrlName(HttpInput::Str(GET, 'pollurlname', false));
}
catch(Exceptions\SeException $ex){
http_response_code(404);
include(WEB_ROOT . '/404.php');
exit();
}
?><?= Template::Header(['title' => 'Results for the ' . $poll->Name . ' poll', 'highlight' => '', 'description' => 'The voting results for the ' . $poll->Name . ' poll.']) ?>
<main>
<article>
<h1>Results for the <?= Formatter::ToPlainText($poll->Name) ?> Poll</h1>
<p class="center-notice">Total votes: <?= number_format($poll->VoteCount) ?></p>
<? if($poll->IsActive()){ ?>
<? if($poll->End !== null){ ?>
<p class="center-notice">This poll closes on <?= $poll->End->format('F j, Y g:i A') ?>.</p>
<? } ?>
<? }elseif($poll->End !== null){ ?>
<p class="center-notice">This poll closed on <?= $poll->End->format('F j, Y g:i A') ?>.</p>
<? } ?>
<table class="votes">
<tbody>
<? foreach($poll->PollItemsByWinner as $pollItem){ ?>
<tr>
<td><?= Formatter::ToPlainText($pollItem->Name) ?></td>
<td>
<div class="meter">
<div aria-hidden="true">
<p><?= number_format($pollItem->VoteCount) ?></p>
</div>
<meter min="0" max="<?= $poll->VoteCount ?>" value="<?= $pollItem->VoteCount ?>"></meter>
</div>
</td>
</tr>
<? } ?>
</tbody>
</table>
</article>
</main>
<?= Template::Footer() ?>

View file

@ -0,0 +1,58 @@
<?
require_once('Core.php');
use function Safe\session_unset;
session_start();
$vote = $_SESSION['vote'] ?? new Vote();
$exception = $_SESSION['exception'] ?? null;
$poll = null;
try{
$poll = Poll::GetByUrlName(HttpInput::Str(GET, 'pollurlname', false));
}
catch(Exceptions\SeException $ex){
http_response_code(404);
include(WEB_ROOT . '/404.php');
exit();
}
if($exception){
http_response_code(400);
session_unset();
}
?><?= Template::Header(['title' => $poll->Name . ' - Vote Now', 'highlight' => '', 'description' => 'Vote in the ' . $poll->Name . ' poll']) ?>
<main>
<article>
<h1>Vote in the <?= Formatter::ToPlainText($poll->Name) ?> Poll</h1>
<?= Template::Error(['exception' => $exception]) ?>
<form method="post" action="<?= Formatter::ToPlainText($poll->Url) ?>/votes">
<label class="email">Your email address
<input type="email" name="email" value="<? if($vote->User !== null){ ?><?= Formatter::ToPlainText($vote->User->Email) ?><? } ?>" maxlength="80" required="required" />
</label>
<fieldset>
<p>Select one of these options</p>
<ul>
<? foreach($poll->PollItems as $pollItem){ ?>
<li>
<label class="checkbox">
<input type="radio" value="<?= $pollItem->PollItemId ?>" name="pollitemid" required="required"<? if($vote->PollItemId == $pollItem->PollItemId){ ?> checked="checked"<? } ?>/>
<span>
<b><?= Formatter::ToPlainText($pollItem->Name) ?></b>
<? if($pollItem->Description !== null){ ?>
<span><?= Formatter::ToPlainText($pollItem->Description) ?></span>
<? } ?>
</span>
</label>
</li>
<? } ?>
</ul>
</fieldset>
<button>Vote</button>
</form>
</article>
</main>
<?= Template::Footer() ?>

View file

@ -0,0 +1,51 @@
<?
require_once('Core.php');
use function Safe\preg_match;
use function Safe\session_unset;
if(HttpInput::RequestMethod() != HTTP_POST){
http_response_code(405);
exit();
}
session_start();
$requestType = HttpInput::RequestType();
$vote = new Vote();
try{
$error = new Exceptions\ValidationException();
$vote->PollItemId = HttpInput::Int(POST, 'pollitemid');
$vote->Create(HttpInput::Str(POST, 'email', false));
session_unset();
if($requestType == WEB){
http_response_code(303);
header('Location: ' . $vote->PollItem->Poll->Url . '/votes/success');
}
else{
// Access via REST api; 201 CREATED with location
http_response_code(201);
header('Location: ' . $vote->Url);
}
}
catch(Exceptions\SeException $ex){
// Validation failed
if($requestType == WEB){
$_SESSION['vote'] = $vote;
$_SESSION['exception'] = $ex;
// Access via form; 303 redirect to the form, which will emit a 400 BAD REQUEST
http_response_code(303);
header('Location: /patrons-circle/polls/' . HttpInput::Str(GET, 'pollurlname', false) . '/votes/new');
}
else{
// Access via REST api; 400 BAD REQUEST
http_response_code(400);
}
}

View file

@ -0,0 +1,23 @@
<?
require_once('Core.php');
$poll = null;
try{
$poll = Poll::GetByUrlName(HttpInput::Str(GET, 'pollurlname', false));
}
catch(Exceptions\SeException $ex){
http_response_code(404);
include(WEB_ROOT . '/404.php');
exit();
}
?><?= Template::Header(['title' => 'Thank you for voting!', 'highlight' => 'newsletter', 'description' => 'Thank you for voting in a Standard Ebooks poll!']) ?>
<main>
<article>
<h1>Thank you for voting!</h1>
<p class="center-notice">Your vote in the <?= Formatter::ToPlainText($poll->Name) ?> poll has been recorded.</p>
<p class="button-row narrow"><a class="button" href="<?= $poll->Url ?>/votes"> view results</a></p>
</article>
</main>
<?= Template::Footer() ?>

View file

@ -17,7 +17,7 @@ $lastPushHashFlag = '';
try{
$log->Write('Received GitHub webhook.');
if($_SERVER['REQUEST_METHOD'] != 'POST'){
if(HttpInput::RequestMethod() != HTTP_POST){
throw new Exceptions\WebhookException('Expected HTTP POST.');
}

View file

@ -15,7 +15,7 @@ try{
$log->Write('Received Postmark webhook.');
if($_SERVER['REQUEST_METHOD'] != 'POST'){
if(HttpInput::RequestMethod() != HTTP_POST){
throw new Exceptions\WebhookException('Expected HTTP POST.');
}
@ -36,7 +36,7 @@ try{
// Received when a user marks an email as spam
$log->Write('Event type: spam complaint.');
Db::Query('delete from NewsletterSubscribers where Email = ?', [$post->Email]);
Db::Query('DELETE from NewsletterSubscribers where Email = ?', [$post->Email]);
}
elseif($post->RecordType == 'SubscriptionChange' && $post->SuppressSending){
// Received when a user clicks Postmark's "Unsubscribe" link in a newsletter email
@ -45,7 +45,7 @@ try{
$email = $post->Recipient;
// Remove the email from our newsletter list
Db::Query('delete from NewsletterSubscribers where Email = ?', [$email]);
Db::Query('DELETE from NewsletterSubscribers where Email = ?', [$email]);
// Remove the suppression from Postmark, since we deleted it from our own list we will never email them again anyway
$handle = curl_init();

View file

@ -1,6 +1,7 @@
<?
require_once('Core.php');
use Safe\DateTime;
use function Safe\file_get_contents;
use function Safe\preg_match;
use function Safe\preg_replace;
@ -15,7 +16,7 @@ $log = new Log(ZOHO_WEBHOOK_LOG_FILE_PATH);
try{
$log->Write('Received Zoho webhook.');
if($_SERVER['REQUEST_METHOD'] != 'POST'){
if(HttpInput::RequestMethod() != HTTP_POST){
throw new Exceptions\WebhookException('Expected HTTP POST.');
}
@ -53,7 +54,7 @@ try{
$payment->Create();
}
else{
Db::Query('insert into PendingPayments (Timestamp, ChannelId, TransactionId) values (utc_timestamp(), ?, ?);', [PAYMENT_CHANNEL_FA, $transactionId]);
Db::Query('INSERT into PendingPayments (Timestamp, ChannelId, TransactionId) values (utc_timestamp(), ?, ?);', [PAYMENT_CHANNEL_FA, $transactionId]);
}
$log->Write('Donation ID: ' . $transactionId);