From ca30d9d42c3b8f5c914404c03c7d574cd6ac7b2d Mon Sep 17 00:00:00 2001 From: "Florine W. Dekker" Date: Thu, 1 Dec 2022 22:24:20 +0100 Subject: [PATCH] Move tracking processing logic to Action classes --- composer.json | 2 +- composer.lock | Bin 77196 -> 77196 bytes package-lock.json | Bin 226174 -> 226174 bytes package.json | 2 +- src/main/api.php | 10 +- .../com/fwdekker/deathnotifier/Database.php | 4 +- .../deathnotifier/EmulateCronCliAction.php | 1 + .../deathnotifier/InvalidStateException.php | 10 + .../com/fwdekker/deathnotifier/Response.php | 4 +- .../deathnotifier/StartSessionAction.php | 4 +- .../fwdekker/deathnotifier/mailer/Email.php | 8 +- .../fwdekker/deathnotifier/mailer/Mailer.php | 6 +- .../mediawiki/IsPersonPageRule.php | 91 +++++ .../{Mediawiki.php => MediaWiki.php} | 26 +- .../mediawiki/MediaWikiException.php | 13 + .../{tracking => mediawiki}/PersonStatus.php | 2 +- .../tracking/AddTrackingAction.php | 63 +++- .../tracking/ListTrackingsAction.php | 24 +- .../tracking/NotifyArticleDeletedEmail.php | 2 +- .../tracking/NotifyArticleUndeletedEmail.php | 2 +- .../tracking/NotifyStatusChangedEmail.php | 4 +- .../tracking/RemoveTrackingAction.php | 22 +- .../tracking/TrackingManager.php | 333 +++++++----------- .../tracking/UpdateTrackingsCliAction.php | 74 +++- .../deathnotifier/user/ChangedEmailEmail.php | 2 +- .../deathnotifier/user/RegisterEmail.php | 2 +- .../deathnotifier/user/ResetPasswordEmail.php | 2 +- .../deathnotifier/user/UserManager.php | 6 +- .../deathnotifier/user/VerifyEmailEmail.php | 2 +- .../deathnotifier/validator/HasLengthRule.php | 6 +- .../deathnotifier/validator/IsEqualToRule.php | 2 +- .../fwdekker/deathnotifier/validator/Rule.php | 2 +- 32 files changed, 459 insertions(+), 272 deletions(-) create mode 100644 src/main/php/com/fwdekker/deathnotifier/InvalidStateException.php create mode 100644 src/main/php/com/fwdekker/deathnotifier/mediawiki/IsPersonPageRule.php rename src/main/php/com/fwdekker/deathnotifier/mediawiki/{Mediawiki.php => MediaWiki.php} (92%) create mode 100644 src/main/php/com/fwdekker/deathnotifier/mediawiki/MediaWikiException.php rename src/main/php/com/fwdekker/deathnotifier/{tracking => mediawiki}/PersonStatus.php (81%) diff --git a/composer.json b/composer.json index a9d9e87..b63e9cc 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "fwdekker/death-notifier", "description": "Get notified when a famous person dies.", - "version": "0.15.3", "_comment_version": "Also update version in `package.json`!", + "version": "0.15.4", "_comment_version": "Also update version in `package.json`!", "type": "project", "license": "MIT", "homepage": "https://git.fwdekker.com/tools/death-notifier", diff --git a/composer.lock b/composer.lock index d66640ae31ec86b6582f9a29f77abba50748c1f1..888106b6632d3f96ca87415f742dd41fd05ac2c7 100644 GIT binary patch delta 51 zcmeCV&C+w5WrHxILXxSeg;}Cua$;&yN>ZY!iFr!0adK*6vW2mMg<-ORX|ploc4J1y GNF4yQ6%SJY delta 51 zcmeCV&C+w5WrHxIf=RMjl8I%qabjAkftiJ+xuKbwithName("Database"), $config["database"]["filename"]); $mailer = new Mailer($config, $logger->withName("Mailer"), $db->conn); -$mediawiki = new Mediawiki($logger->withName("Mediawiki")); +$mediawiki = new MediaWiki($logger->withName("Mediawiki")); $user_manager = new UserManager($logger->withName("UserManager"), $db->conn, $mailer); -$tracking_manager = new TrackingManager($logger->withName("TrackingManager"), $db->conn, $mailer, $mediawiki); +$tracking_manager = new TrackingManager($logger->withName("TrackingManager"), $db->conn); // Handle request @@ -75,11 +75,11 @@ try { $dispatcher->register_action(new SendPasswordResetAction($user_manager)); $dispatcher->register_action(new ResetPasswordAction($user_manager)); $dispatcher->register_action(new UserDeleteAction($user_manager)); - $dispatcher->register_action(new AddTrackingAction($tracking_manager)); + $dispatcher->register_action(new AddTrackingAction($tracking_manager, $mediawiki)); $dispatcher->register_action(new RemoveTrackingAction($tracking_manager)); // CLI actions $cli_actions = [ - new UpdateTrackingsCliAction($config, $tracking_manager), + new UpdateTrackingsCliAction($config, $db->conn, $tracking_manager, $mediawiki, $mailer), new ProcessEmailQueueCliAction($config, $mailer), ]; $dispatcher->register_action($cli_actions[0]); diff --git a/src/main/php/com/fwdekker/deathnotifier/Database.php b/src/main/php/com/fwdekker/deathnotifier/Database.php index 711ca4d..9932304 100644 --- a/src/main/php/com/fwdekker/deathnotifier/Database.php +++ b/src/main/php/com/fwdekker/deathnotifier/Database.php @@ -21,11 +21,11 @@ class Database public const LATEST_VERSION = "%%DB_VERSION_NUMBER%%"; /** - * @var Logger The logger to use for logging. + * @var Logger the logger to use for logging */ private Logger $logger; /** - * @var PDO The PDO object that connects to the database. + * @var PDO the PDO object that connects to the database */ public PDO $conn; diff --git a/src/main/php/com/fwdekker/deathnotifier/EmulateCronCliAction.php b/src/main/php/com/fwdekker/deathnotifier/EmulateCronCliAction.php index 32546a3..eee6f1d 100644 --- a/src/main/php/com/fwdekker/deathnotifier/EmulateCronCliAction.php +++ b/src/main/php/com/fwdekker/deathnotifier/EmulateCronCliAction.php @@ -52,6 +52,7 @@ class EmulateCronCliAction extends Action */ public function handle(): never { + // @phpstan-ignore-next-line while (true) { print("Emulating cron jobs.\n"); foreach ($this->actions as $action) diff --git a/src/main/php/com/fwdekker/deathnotifier/InvalidStateException.php b/src/main/php/com/fwdekker/deathnotifier/InvalidStateException.php new file mode 100644 index 0000000..13ebd5c --- /dev/null +++ b/src/main/php/com/fwdekker/deathnotifier/InvalidStateException.php @@ -0,0 +1,10 @@ + the application's configuration */ private readonly array $config; /** @@ -24,7 +24,7 @@ class StartSessionAction extends Action /** * Constructs a new `StartSessionAction`. * - * @param mixed $config the application's configuration + * @param array $config the application's configuration * @param UserManager $user_manager the manager to validate the session through */ public function __construct(array $config, UserManager $user_manager) diff --git a/src/main/php/com/fwdekker/deathnotifier/mailer/Email.php b/src/main/php/com/fwdekker/deathnotifier/mailer/Email.php index 0e7f1ba..72ecd82 100644 --- a/src/main/php/com/fwdekker/deathnotifier/mailer/Email.php +++ b/src/main/php/com/fwdekker/deathnotifier/mailer/Email.php @@ -22,19 +22,19 @@ use Exception; abstract class Email { /** - * @var string A string identifying the type of email. + * @var string a string identifying the type of email */ public string $type; /** - * @var string The intended recipient of the email. + * @var string the intended recipient of the email */ public string $recipient; /** - * @var string The first argument to construct the email. + * @var string the first argument to construct the email */ public string $arg1 = ""; /** - * @var string The second argument to construct the email. + * @var string the second argument to construct the email */ public string $arg2 = ""; diff --git a/src/main/php/com/fwdekker/deathnotifier/mailer/Mailer.php b/src/main/php/com/fwdekker/deathnotifier/mailer/Mailer.php index 65d48c1..2c8dc2d 100644 --- a/src/main/php/com/fwdekker/deathnotifier/mailer/Mailer.php +++ b/src/main/php/com/fwdekker/deathnotifier/mailer/Mailer.php @@ -17,15 +17,15 @@ use PHPMailer\PHPMailer\SMTP; class Mailer { /** - * @var Logger The logger to use for logging. + * @var Logger the logger to use for logging */ private Logger $logger; /** - * @var array> The configuration to use for mailing. + * @var array> the configuration to use for mailing */ private array $config; /** - * @var PDO The database connection to interact with. + * @var PDO the database connection to interact with */ private PDO $conn; diff --git a/src/main/php/com/fwdekker/deathnotifier/mediawiki/IsPersonPageRule.php b/src/main/php/com/fwdekker/deathnotifier/mediawiki/IsPersonPageRule.php new file mode 100644 index 0000000..80cd1bd --- /dev/null +++ b/src/main/php/com/fwdekker/deathnotifier/mediawiki/IsPersonPageRule.php @@ -0,0 +1,91 @@ +mediawiki = $mediawiki; + } + + + /** + * Verifies that the input refers to a page about a person on Wikipedia. + * + * @param array $inputs the list of inputs in which the value at `key` should be checked + * @param string $key the key in `inputs` of the input to check + * @return void if `$inputs[$key]` refers to a page about a person on Wikipedia + * @throws ValidationException if `$inputs[$key]` is not set or does not refer to a page about a person on Wikipedia + */ + public function check(array $inputs, string $key): void + { + if (!isset($inputs[$key])) throw new ValidationException($this->override_message ?? "Field must be set.", $key); + + $person_name = $inputs[$key]; + try { + $info = $this->mediawiki->query_person_info([$person_name]); + } catch (MediaWikiException) { + // TODO: Log this exception! +// $this->logger->error("Failed to query page info.", ["cause" => $exception, "name" => $person_name]); + throw new ValidationException( + $this->override_message ?? "Could not reach Wikipedia. Maybe the website is down?" + ); + } + + $normalized_name = $info->redirects[$person_name]; + $status = $info->results[$normalized_name]["status"]; + $type = $info->results[$normalized_name]["type"]; + + if (in_array($normalized_name, $info->missing)) + throw new ValidationException( + $this->override_message ?? + "Wikipedia does not have an article about " . + "" . htmlentities($person_name) . ". " . + "Maybe you need to capitalise the surname?", + "person_name" + ); + + if ($type === ArticleType::Disambiguation) + throw new ValidationException( + $this->override_message ?? + "" . + htmlentities($normalized_name) . " refers to multiple articles. " . + "Check Wikipedia " . + "to see if your article is listed.", + "person_name" + ); + + if ($type === ArticleType::Other) + throw new ValidationException( + $this->override_message ?? + "The Wikipedia article about " . + "" . + htmlentities($normalized_name) . " is not about a real-world person.", + "person_name" + ); + } +} diff --git a/src/main/php/com/fwdekker/deathnotifier/mediawiki/Mediawiki.php b/src/main/php/com/fwdekker/deathnotifier/mediawiki/MediaWiki.php similarity index 92% rename from src/main/php/com/fwdekker/deathnotifier/mediawiki/Mediawiki.php rename to src/main/php/com/fwdekker/deathnotifier/mediawiki/MediaWiki.php index 0cf76f1..2bd8bc8 100644 --- a/src/main/php/com/fwdekker/deathnotifier/mediawiki/Mediawiki.php +++ b/src/main/php/com/fwdekker/deathnotifier/mediawiki/MediaWiki.php @@ -2,15 +2,13 @@ namespace com\fwdekker\deathnotifier\mediawiki; -use com\fwdekker\deathnotifier\tracking\PersonStatus; -use Exception; use Monolog\Logger; /** * Helper class for interacting with Wikipedia's API. */ -class Mediawiki +class MediaWiki { /** * The URL of Wikipedia's API endpoint. @@ -35,7 +33,7 @@ class Mediawiki private const CATS_PER_QUERY = 500; /** - * @var Logger The logger to use for logging. + * @var Logger the logger to use for logging */ private Logger $logger; // @phpstan-ignore-line Unused, but useful for debugging @@ -56,7 +54,7 @@ class Mediawiki * * @param array $params the request parameters to send to the API * @return mixed a JSON object containing the API's response - * @throws Exception if the request fails + * @throws MediaWikiException() if the request fails */ private function api_fetch(array $params): mixed { @@ -68,7 +66,7 @@ class Mediawiki $output = curl_exec($ch); curl_close($ch); if (is_bool($output) || curl_error($ch)) - throw new Exception(curl_error($ch)); + throw new MediaWikiException(curl_error($ch)); return json_decode($output, associative: true); } @@ -80,7 +78,7 @@ class Mediawiki * @param string|null $continue_name the name of the continue parameter to follow, or `null` if no continuation * should be done * @return mixed[] the query's value of the `query` key as a JSON object - * @throws Exception if the query fails + * @throws MediaWikiException if the query fails */ private function api_query_continued(array $params, ?string $continue_name = null): array { @@ -116,7 +114,8 @@ class Mediawiki * @param string[] $titles the titles to query * @param string|null $continue_name the name of the continue parameter used for this request by the API * @return QueryOutput the API's response - * @throws Exception if the query fails + * @throws MediaWikiException if the query fails + * @noinspection PhpSameParameterValueInspection `$continue_name` may take other values in the future */ private function api_query_batched(array $params, array $titles, ?string $continue_name): QueryOutput { @@ -202,12 +201,11 @@ class Mediawiki * Checks for each person what their status is according to Wikipedia's categorization. * * @param string[] $names the names of the people to check aliveness of - * @return QueryOutput a query output with results mapping - * each article's normalized title to the person's status (or `null` if the article is not about a person) and to - * the article's type - * @throws Exception if the query fails + * @return QueryOutput a query output with results mapping + * each article's normalized title to the article's type and, if the article is about a person, the person's status + * @throws MediaWikiException if the query fails */ - public function query_page_info(array $names): QueryOutput + public function query_person_info(array $names): QueryOutput { $output = $this->api_query_batched( params: ["prop" => "categories", "cllimit" => strval(self::CATS_PER_QUERY)], @@ -220,8 +218,8 @@ class Mediawiki array_column($output->results, "title"), array_map( fn($it) => [ + "type" => $this->article_type($it), "status" => $this->person_status($it), - "type" => $this->article_type($it) ], $output->results ) diff --git a/src/main/php/com/fwdekker/deathnotifier/mediawiki/MediaWikiException.php b/src/main/php/com/fwdekker/deathnotifier/mediawiki/MediaWikiException.php new file mode 100644 index 0000000..9928c0b --- /dev/null +++ b/src/main/php/com/fwdekker/deathnotifier/mediawiki/MediaWikiException.php @@ -0,0 +1,13 @@ + [ new IsNotBlankRule(), - new HasLengthRule(TrackingManager::MIN_TITLE_LENGTH, TrackingManager::MAX_TITLE_LENGTH) + new HasLengthRule(TrackingManager::MIN_TITLE_LENGTH, TrackingManager::MAX_TITLE_LENGTH), + new IsPersonPageRule($mediawiki) ], ], ); $this->tracking_manager = $tracking_manager; + $this->mediawiki = $mediawiki; } - function handle(): mixed + /** + * Adds a tracking by the current user of `$_POST["person_name"]`. + * + * @return array{"name": string, "input": string, "renamed": bool} the + * @throws ActionException if the Wikipedia API could not be reached + * @throws ValidationException if the user is already tracking this person + */ + function handle(): array { - $response = $this->tracking_manager->add_tracking($_SESSION["uuid"], $_POST["person_name"]); - if (!$response->satisfied) - throw new ActionException($response->payload["message"], $response->payload["target"]); + $user_uuid = $_SESSION["uuid"]; + $person_name = strval($_POST["person_name"]); - return $response->payload; + // Query API + try { + $info = $this->mediawiki->query_person_info([$person_name]); + } catch (MediaWikiException $exception) { + // TODO: Log this? +// $this->logger->error("Failed to query page info.", ["cause" => $exception, "name" => $person_name]); + throw new ActionException("Could not reach Wikipedia. Maybe the website is down?"); + } + + $normalized_name = $info->redirects[$person_name]; + $status = $info->results[$normalized_name]["status"]; + if ($status === null) + throw new InvalidStateException("Article is about person, but person has no status."); + + // Add tracking (Transaction is not necessary) + if ($this->tracking_manager->has_tracking($user_uuid, $normalized_name)) + throw new ValidationException("You are already tracking $normalized_name."); + + $this->tracking_manager->add_tracking($user_uuid, $normalized_name, $status); + + // Respond + return [ + "name" => $normalized_name, + "input" => $person_name, + "renamed" => strtolower($person_name) !== strtolower($normalized_name) + ]; } } diff --git a/src/main/php/com/fwdekker/deathnotifier/tracking/ListTrackingsAction.php b/src/main/php/com/fwdekker/deathnotifier/tracking/ListTrackingsAction.php index c7bf96b..b1fb957 100644 --- a/src/main/php/com/fwdekker/deathnotifier/tracking/ListTrackingsAction.php +++ b/src/main/php/com/fwdekker/deathnotifier/tracking/ListTrackingsAction.php @@ -7,11 +7,22 @@ use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\ActionMethod; +/** + * Lists all trackings of the current user. + */ class ListTrackingsAction extends Action { + /** + * @var TrackingManager the manager to fetch trackings with + */ private readonly TrackingManager $tracking_manager; + /** + * Constructs a new `ListTrackingsAction`. + * + * @param TrackingManager $tracking_manager the manager to fetch trackings with + */ public function __construct(TrackingManager $tracking_manager) { parent::__construct( @@ -25,12 +36,13 @@ class ListTrackingsAction extends Action } - function handle(): mixed + /** + * Returns all trackings of the current user. + * + * @return mixed[] all trackings of the current user + */ + function handle(): array { - $response = $this->tracking_manager->list_trackings($_SESSION["uuid"]); - if (!$response->satisfied) - throw new ActionException($response->payload["message"], $response->payload["target"]); - - return $response->payload; + return $this->tracking_manager->list_trackings($_SESSION["uuid"]); } } diff --git a/src/main/php/com/fwdekker/deathnotifier/tracking/NotifyArticleDeletedEmail.php b/src/main/php/com/fwdekker/deathnotifier/tracking/NotifyArticleDeletedEmail.php index d1234eb..4349b1b 100644 --- a/src/main/php/com/fwdekker/deathnotifier/tracking/NotifyArticleDeletedEmail.php +++ b/src/main/php/com/fwdekker/deathnotifier/tracking/NotifyArticleDeletedEmail.php @@ -16,7 +16,7 @@ class NotifyArticleDeletedEmail extends Email public const TYPE = "notify-article-deleted"; /** - * @var string The name of the article that was deleted. + * @var string the name of the article that was deleted */ public string $name; diff --git a/src/main/php/com/fwdekker/deathnotifier/tracking/NotifyArticleUndeletedEmail.php b/src/main/php/com/fwdekker/deathnotifier/tracking/NotifyArticleUndeletedEmail.php index b304168..47becf9 100644 --- a/src/main/php/com/fwdekker/deathnotifier/tracking/NotifyArticleUndeletedEmail.php +++ b/src/main/php/com/fwdekker/deathnotifier/tracking/NotifyArticleUndeletedEmail.php @@ -16,7 +16,7 @@ class NotifyArticleUndeletedEmail extends Email public const TYPE = "notify-article-undeleted"; /** - * @var string The name of the article that was re-created. + * @var string the name of the article that was re-created */ public string $name; diff --git a/src/main/php/com/fwdekker/deathnotifier/tracking/NotifyStatusChangedEmail.php b/src/main/php/com/fwdekker/deathnotifier/tracking/NotifyStatusChangedEmail.php index 7323fdb..76bdadc 100644 --- a/src/main/php/com/fwdekker/deathnotifier/tracking/NotifyStatusChangedEmail.php +++ b/src/main/php/com/fwdekker/deathnotifier/tracking/NotifyStatusChangedEmail.php @@ -16,11 +16,11 @@ class NotifyStatusChangedEmail extends Email public const TYPE = "notify-status-changed"; /** - * @var string The name of the person whose status has changed. + * @var string the name of the person whose status has changed */ public string $name; /** - * @var string The new status of the person. + * @var string the new status of the person */ public string $new_status; diff --git a/src/main/php/com/fwdekker/deathnotifier/tracking/RemoveTrackingAction.php b/src/main/php/com/fwdekker/deathnotifier/tracking/RemoveTrackingAction.php index 6d3ad5f..0062d13 100644 --- a/src/main/php/com/fwdekker/deathnotifier/tracking/RemoveTrackingAction.php +++ b/src/main/php/com/fwdekker/deathnotifier/tracking/RemoveTrackingAction.php @@ -8,11 +8,22 @@ use com\fwdekker\deathnotifier\ActionMethod; use com\fwdekker\deathnotifier\validator\IsNotBlankRule; +/** + * Removes a tracking of the current user. + */ class RemoveTrackingAction extends Action { + /** + * @var TrackingManager the manager to remove the tracking with + */ private readonly TrackingManager $tracking_manager; + /** + * Constructs a new `RemoveTrackingAction`. + * + * @param TrackingManager $tracking_manager the manager to remove the tracking with + */ public function __construct(TrackingManager $tracking_manager) { parent::__construct( @@ -27,12 +38,15 @@ class RemoveTrackingAction extends Action } + /** + * Removes the current user's tracking of `$_POST["person_name"]`. + * + * @return mixed `null` + */ function handle(): mixed { - $response = $this->tracking_manager->remove_tracking($_SESSION["uuid"], $_POST["person_name"]); - if (!$response->satisfied) - throw new ActionException($response->payload["message"], $response->payload["target"]); + $this->tracking_manager->remove_tracking($_SESSION["uuid"], $_POST["person_name"]); - return $response->payload; + return null; } } diff --git a/src/main/php/com/fwdekker/deathnotifier/tracking/TrackingManager.php b/src/main/php/com/fwdekker/deathnotifier/tracking/TrackingManager.php index 32cc2ce..94f9835 100644 --- a/src/main/php/com/fwdekker/deathnotifier/tracking/TrackingManager.php +++ b/src/main/php/com/fwdekker/deathnotifier/tracking/TrackingManager.php @@ -3,11 +3,8 @@ namespace com\fwdekker\deathnotifier\tracking; use com\fwdekker\deathnotifier\Database; -use com\fwdekker\deathnotifier\mailer\Mailer; use com\fwdekker\deathnotifier\mediawiki\ArticleType; -use com\fwdekker\deathnotifier\mediawiki\Mediawiki; -use com\fwdekker\deathnotifier\Response; -use Exception; +use com\fwdekker\deathnotifier\mediawiki\PersonStatus; use Monolog\Logger; use PDO; @@ -27,21 +24,13 @@ class TrackingManager public const MAX_TITLE_LENGTH = 255; /** - * @var Logger The logger to log with. + * @var Logger the logger to log with */ private Logger $logger; /** - * @var PDO The database connection to interact with. + * @var PDO the database connection to interact with */ private PDO $conn; - /** - * @var Mailer The mailer to send emails with. - */ - private Mailer $mailer; - /** - * @var Mediawiki The Mediawiki instance to use for interacting with Wikipedia. - */ - private Mediawiki $mediawiki; /** @@ -49,15 +38,11 @@ class TrackingManager * * @param Logger $logger the logger to log with * @param PDO $conn the database connection to interact with - * @param Mailer $mailer the mailer to send emails with - * @param Mediawiki $mediawiki the Mediawiki instance to use for interacting with Wikipedia */ - public function __construct(Logger $logger, PDO $conn, Mailer $mailer, Mediawiki $mediawiki) + public function __construct(Logger $logger, PDO $conn) { $this->logger = $logger; $this->conn = $conn; - $this->mailer = $mailer; - $this->mediawiki = $mediawiki; } @@ -96,112 +81,72 @@ class TrackingManager * * @param string $user_uuid the user to whom the tracking belongs * @param string $person_name the name of the person to track - * @return Response a satisfied `Response` with payload `null` if the tracking was added, or an unsatisfied - * `Response` otherwise + * @param PersonStatus $status the status of the person to track + * @return void */ - public function add_tracking(string $user_uuid, string $person_name): Response + public function add_tracking(string $user_uuid, string $person_name, PersonStatus $status): void { - try { - $info = $this->mediawiki->query_page_info([$person_name]); - } catch (Exception $exception) { - $this->logger->error("Failed to query page info.", ["cause" => $exception, "name" => $person_name]); - return Response::unsatisfied("Could not reach Wikipedia. Maybe the website is down?"); - } - - $normalized_name = $info->redirects[$person_name]; - $status = $info->results[$normalized_name]["status"]; - $type = $info->results[$normalized_name]["type"]; - - if (in_array($normalized_name, $info->missing)) - return Response::unsatisfied( - "Wikipedia does not have an article about " . - "" . htmlentities($person_name) . ". " . - "Maybe you need to capitalise the surname?", - "person_name" - ); - if ($type === ArticleType::Disambiguation) - return Response::unsatisfied( - "" . - htmlentities($normalized_name) . " refers to multiple articles. " . - "Check Wikipedia " . - "to see if your article is listed.", - "person_name" - ); - if ($type === ArticleType::Other) - return Response::unsatisfied( - "The Wikipedia article about " . - "" . - htmlentities($normalized_name) . " is not about a person.", - "person_name" - ); - if ($status === null) - return Response::unsatisfied("Failed to understand the person's status. This is a bug. Try again maybe?"); - - // Insert person and tracking - return Database::transaction( + Database::transaction( $this->conn, - function () use ($user_uuid, $person_name, $normalized_name, $status) { - $stmt = $this->conn->prepare("SELECT EXISTS(SELECT 1 - FROM trackings - WHERE user_uuid=:uuid AND person_name=:name);"); - $stmt->bindValue(":uuid", $user_uuid); - $stmt->bindValue(":name", $normalized_name); - $stmt->execute(); - if ($stmt->fetch()[0] === 1) - return Response::unsatisfied( - "You are already tracking " . htmlentities($normalized_name) . ".", - "person_name" - ); - + function () use ($user_uuid, $person_name, $status) { $stmt = $this->conn->prepare("INSERT OR IGNORE INTO people (name) VALUES (:name);"); - $stmt->bindValue(":name", $normalized_name); + $stmt->bindValue(":name", $person_name); $stmt->execute(); $stmt = $this->conn->prepare("UPDATE people SET status=:status WHERE name=:name;"); - $stmt->bindValue(":name", $normalized_name); + $stmt->bindValue(":name", $person_name); $stmt->bindValue(":status", $status->value); $stmt->execute(); $stmt = $this->conn->prepare("INSERT OR IGNORE INTO trackings (user_uuid, person_name) VALUES (:user_uuid, :person_name);"); $stmt->bindValue(":user_uuid", $user_uuid); - $stmt->bindValue(":person_name", $normalized_name); + $stmt->bindValue(":person_name", $person_name); $stmt->execute(); - - return Response::satisfied([ - "name" => $normalized_name, - "input" => $person_name, - "renamed" => strtolower($person_name) !== strtolower($normalized_name) - ]); }); } + /** + * Returns `true` if and only if the indicated user currently tracks the indicated person. + * + * @param string $user_uuid the user to check + * @param string $person_name the person to check + * @return bool `true` if and only if the indicated user currently tracks the indicated person + */ + public function has_tracking(string $user_uuid, string $person_name): bool + { + $stmt = $this->conn->prepare("SELECT EXISTS(SELECT 1 + FROM trackings + WHERE user_uuid=:uuid AND person_name=:name);"); + $stmt->bindValue(":uuid", $user_uuid); + $stmt->bindValue(":name", $person_name); + $stmt->execute(); + return $stmt->fetch()[0] === 1; + } + /** * Removes a tracking from the database. * * @param string $user_uuid the user to whom the tracking belongs * @param string $person_name the name of the tracked person to remove - * @return Response a satisfied `Response` with payload `null` + * @return void */ - public function remove_tracking(string $user_uuid, string $person_name): Response + public function remove_tracking(string $user_uuid, string $person_name): void { $stmt = $this->conn->prepare("DELETE FROM trackings WHERE user_uuid=:user_uuid AND person_name=:person_name;"); $stmt->bindValue(":user_uuid", $user_uuid); $stmt->bindValue(":person_name", $person_name); $stmt->execute(); - - return Response::satisfied(); } /** - * Lists all trackings of the indicated user. + * Returns all trackings of the indicated user. * * @param string $user_uuid the user to return the trackings of - * @return Response a satisfied `Response` with the trackings as its payload + * @return array all trackings of the indicated user */ - public function list_trackings(string $user_uuid): Response + public function list_trackings(string $user_uuid): array { $stmt = $this->conn->prepare("SELECT people.name, people.status, people.is_deleted FROM trackings @@ -210,9 +155,7 @@ class TrackingManager AND trackings.person_name=people.name;"); $stmt->bindValue(":user_uuid", $user_uuid); $stmt->execute(); - $results = $stmt->fetchAll(PDO::FETCH_ASSOC); - - return Response::satisfied($results); + return $stmt->fetchAll(PDO::FETCH_ASSOC); } /** @@ -221,18 +164,18 @@ class TrackingManager * @param string $person_name the person to receive subscribed email addresses for * @return string[] the email addresses of all users who should be notified of events relating to the given person */ - private function get_recipients(string $person_name): array + public function list_trackers(string $person_name): array { - $get_recipients = $this->conn->prepare("SELECT users.email - FROM users - LEFT JOIN trackings - WHERE trackings.person_name=:person_name - AND trackings.user_uuid=users.uuid - AND users.email_verification_token IS NULL - AND users.email_notifications_enabled=1;"); - $get_recipients->bindParam(":person_name", $person_name); - $get_recipients->execute(); - return array_column($get_recipients->fetchAll(PDO::FETCH_ASSOC), "email"); + $stmt = $this->conn->prepare("SELECT users.email + FROM users + LEFT JOIN trackings + WHERE trackings.person_name=:person_name + AND trackings.user_uuid=users.uuid + AND users.email_verification_token IS NULL + AND users.email_notifications_enabled=1;"); + $stmt->bindParam(":person_name", $person_name); + $stmt->execute(); + return array_column($stmt->fetchAll(PDO::FETCH_ASSOC), "email"); } @@ -248,136 +191,122 @@ class TrackingManager return array_column($stmt->fetchAll(PDO::FETCH_ASSOC), "name"); } - /** - * Updates trackings for the given names. - * - * @param string[] $names the names of the trackings to update - * @return void - */ - public function update_trackings(array $names): void - { - if (empty($names)) return; - - try { - $people_statuses = $this->mediawiki->query_page_info($names); - } catch (Exception $exception) { - $this->logger->error("Failed to retrieve page information.", ["cause" => $exception, "pages" => $names]); - return; - } - - Database::transaction($this->conn, function () use ($people_statuses) { - $this->process_redirects($people_statuses->redirects); - $this->process_deletions($people_statuses->missing); - $this->process_statuses($people_statuses->results); - return Response::satisfied(); - }); - } - /** * Renames people in the database. * - * @param array $redirects a map of all changes, from old name to new name + * @param array $renamings a map of all changes, from old name to new name * @return void */ - private function process_redirects(array $redirects): void + public function rename_persons(array $renamings): void { - // Query to rename person - $rename = $this->conn->prepare("UPDATE people SET name=:new_name WHERE name=:old_name;"); - $rename->bindParam(":old_name", $from); - $rename->bindParam(":new_name", $to); + Database::transaction($this->conn, function () use ($renamings) { + // Query to rename person + $rename = $this->conn->prepare("UPDATE people SET name=:new_name WHERE name=:old_name;"); + $rename->bindParam(":old_name", $from); + $rename->bindParam(":new_name", $to); - // Query to see if row with new name already exists - $merge_needed = $this->conn->prepare("SELECT EXISTS(SELECT 1 FROM people WHERE name=:new_name);"); - $merge_needed->bindParam(":new_name", $to); + // Query to see if row with new name already exists + $merge_needed = $this->conn->prepare("SELECT EXISTS(SELECT 1 FROM people WHERE name=:new_name);"); + $merge_needed->bindParam(":new_name", $to); - // Queries to merge old name row with new name row - $merge_update = $this->conn->prepare("UPDATE OR IGNORE trackings - SET person_name=:new_name - WHERE person_name=:old_name;"); - $merge_update->bindParam(":old_name", $from); - $merge_update->bindParam(":new_name", $to); + // Queries to merge old name row with new name row + $merge_update = $this->conn->prepare("UPDATE OR IGNORE trackings + SET person_name=:new_name + WHERE person_name=:old_name;"); + $merge_update->bindParam(":old_name", $from); + $merge_update->bindParam(":new_name", $to); - $merge_remove = $this->conn->prepare("DELETE FROM people WHERE name=:old_name;"); - $merge_remove->bindParam(":old_name", $from); + $merge_remove = $this->conn->prepare("DELETE FROM people WHERE name=:old_name;"); + $merge_remove->bindParam(":old_name", $from); - // Perform queries - foreach ($redirects as $from => $to) { - if ($from === $to) continue; + // Perform queries + foreach ($renamings as $from => $to) { + if ($from === $to) continue; - $merge_needed->execute(); - if ($merge_needed->fetch()[0] === 1) { - $merge_update->execute(); - $merge_remove->execute(); - } else { - $rename->execute(); + $merge_needed->execute(); + if ($merge_needed->fetch()[0] === 1) { + $merge_update->execute(); + $merge_remove->execute(); + } else { + $rename->execute(); + } } - } + }); } /** * Deletes people from the database. * * @param string[] $deletions list of names of people to remove from the database - * @return void + * @return string[] list of names of people who were actually removed from the database */ - private function process_deletions(array $deletions): void + public function delete_persons(array $deletions): array { - // Query to delete person, returning `name` to determine whether something changed - $delete = $this->conn->prepare("UPDATE people - SET is_deleted=1 - WHERE name=:name AND is_deleted<>1 - RETURNING name;"); - $delete->bindParam(":name", $deleted_name); + $actual_deletions = []; - foreach ($deletions as $deleted_name) { - $delete->execute(); - $deleted = sizeof($delete->fetchAll(PDO::FETCH_ASSOC)) > 0; - if ($deleted) - foreach ($this->get_recipients($deleted_name) as $user_email) - $this->mailer->queue_email(new NotifyArticleDeletedEmail($user_email, $deleted_name)); - } + Database::transaction($this->conn, function () use ($deletions, &$actual_deletions) { + // Query to delete person, returning `name` to determine whether something changed + $delete = $this->conn->prepare("UPDATE people + SET is_deleted=1 + WHERE name=:name AND is_deleted<>1 + RETURNING name;"); + $delete->bindParam(":name", $deleted_name); + + foreach ($deletions as $deleted_name) { + $delete->execute(); + $deleted = sizeof($delete->fetchAll(PDO::FETCH_ASSOC)) > 0; + + if ($deleted) + $actual_deletions[] = $deleted_name; + } + }); + + return $actual_deletions; } /** * Updates peoples' statuses. * - * @param array $statuses the current statuses of + * @param array $statuses the current statuses of * people - * @return void + * @return array{"undeletions": string[], "status_changes": array} the list of articles that were + * actually undeleted, and a mapping of articles that were actually changes to the new status */ - private function process_statuses(array $statuses): void + public function update_statuses(array $statuses): array { - // TODO: Restrict number of notifications to 1 per hour (excluding "oops we're not sure" message) - // Query to mark person as no longer deleted, returning `name` to determine whether something changed - $undelete = $this->conn->prepare("UPDATE people - SET is_deleted=0 - WHERE name=:name AND is_deleted<>0 - RETURNING name;"); - $undelete->bindParam(":name", $person_name); + $undeletions = []; + $status_changes = []; - // Query to update status, returning `name` to determine whether something changed - $set_status = $this->conn->prepare("UPDATE people - SET status=:status - WHERE name=:name AND status<>:status - RETURNING name;"); - $set_status->bindParam(":status", $person_status); - $set_status->bindParam(":name", $person_name); + Database::transaction($this->conn, function () use ($statuses, &$undeletions, &$status_changes) { + // Query to mark person as no longer deleted, returning `name` to determine whether something changed + $undelete = $this->conn->prepare("UPDATE people + SET is_deleted=0 + WHERE name=:name AND is_deleted<>0 + RETURNING name;"); + $undelete->bindParam(":name", $person_name); - foreach ($statuses as $person_name => $person_info) { - if ($person_info["status"] === null) continue; - $person_status = $person_info["status"]->value; + // Query to update status, returning `name` to determine whether something changed + $set_status = $this->conn->prepare("UPDATE people + SET status=:status + WHERE name=:name AND status<>:status + RETURNING name;"); + $set_status->bindParam(":status", $person_status_string); + $set_status->bindParam(":name", $person_name); - $undelete->execute(); - $undeleted = sizeof($undelete->fetchAll(PDO::FETCH_ASSOC)) > 0; - if ($undeleted) - foreach ($this->get_recipients($person_name) as $user_email) - $this->mailer->queue_email(new NotifyArticleUndeletedEmail($user_email, $person_name)); + foreach ($statuses as $person_name => $person_info) { + if ($person_info["status"] === null) continue; + $person_status_string = $person_info["status"]->value; - $set_status->execute(); - $status_changed = sizeof($set_status->fetchAll(PDO::FETCH_ASSOC)) > 0; - if ($status_changed) - foreach ($this->get_recipients($person_name) as $user_email) - $this->mailer->queue_email(new NotifyStatusChangedEmail($user_email, $person_name, $person_status)); - } + $undelete->execute(); + $undeleted = sizeof($undelete->fetchAll(PDO::FETCH_ASSOC)) > 0; + if ($undeleted) $undeletions[] = $person_name; + + $set_status->execute(); + $status_changed = sizeof($set_status->fetchAll(PDO::FETCH_ASSOC)) > 0; + if ($status_changed) $status_changes[$person_name] = $person_status_string; + } + }); + + return ["undeletions" => $undeletions, "status_changes" => $status_changes]; } } diff --git a/src/main/php/com/fwdekker/deathnotifier/tracking/UpdateTrackingsCliAction.php b/src/main/php/com/fwdekker/deathnotifier/tracking/UpdateTrackingsCliAction.php index c702cc7..d3d0d5d 100644 --- a/src/main/php/com/fwdekker/deathnotifier/tracking/UpdateTrackingsCliAction.php +++ b/src/main/php/com/fwdekker/deathnotifier/tracking/UpdateTrackingsCliAction.php @@ -2,7 +2,13 @@ namespace com\fwdekker\deathnotifier\tracking; +use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\CliAction; +use com\fwdekker\deathnotifier\Database; +use com\fwdekker\deathnotifier\mailer\Mailer; +use com\fwdekker\deathnotifier\mediawiki\MediaWiki; +use com\fwdekker\deathnotifier\mediawiki\MediaWikiException; +use PDO; /** @@ -10,23 +16,42 @@ use com\fwdekker\deathnotifier\CliAction; */ class UpdateTrackingsCliAction extends CliAction { + /** + * @var PDO the database connection to interact with + */ + private readonly PDO $conn; /** * @var TrackingManager the manager through which trackings should be updated */ private readonly TrackingManager $tracking_manager; + /** + * @var MediaWiki the instance to connect to Wikipedia with + */ + private readonly MediaWiki $mediawiki; + /** + * @var Mailer the mailer to send emails with + */ + private readonly Mailer $mailer; /** * Constructs a new `UpdateTrackingsAction`. * * @param mixed $config the application's configuration + * @param PDO $conn the database connection to interact with * @param TrackingManager $tracking_manager the manager through which trackings should be updated + * @param MediaWiki $mediawiki the instance to connect to Wikipedia with + * @param Mailer $mailer the mailer to send emails with */ - public function __construct(mixed $config, TrackingManager $tracking_manager) + public function __construct(mixed $config, PDO $conn, TrackingManager $tracking_manager, MediaWiki $mediawiki, + Mailer $mailer) { parent::__construct($config, "update-trackings"); + $this->conn = $conn; $this->tracking_manager = $tracking_manager; + $this->mediawiki = $mediawiki; + $this->mailer = $mailer; } @@ -34,10 +59,55 @@ class UpdateTrackingsCliAction extends CliAction * Updates all trackings that users have added. * * @return mixed `null` + * @throws ActionException if the Wikipedia API could not be reached */ public function handle(): mixed { - $this->tracking_manager->update_trackings($this->tracking_manager->list_all_unique_person_names()); + $names = $this->tracking_manager->list_all_unique_person_names(); + if (empty($names)) return; + + // Fetch changes + try { + $people_statuses = $this->mediawiki->query_person_info($names); + } catch (MediaWikiException) { + throw new ActionException("Could not reach Wikipedia. Maybe the website is down?"); + // TODO: Log this exception +// $this->logger->error("Failed to retrieve page information.", ["cause" => $exception, "pages" => $names]); + } + + // Process changes + $actual_deletions = []; + $undeletions = []; + $status_changes = []; + Database::transaction( + $this->conn, + function () use ( + $people_statuses, + &$actual_deletions, + &$undeletions, + &$status_changes + ) { + $this->tracking_manager->rename_persons($people_statuses->redirects); + $actual_deletions = $this->tracking_manager->delete_persons($people_statuses->missing); + ["undeletions" => $undeletions, "status_changes" => $status_changes] = + $this->tracking_manager->update_statuses($people_statuses->results); + } + ); + + // Send mails + // TODO: Restrict number of notifications to 1 per hour (excluding "oops we're not sure" message) + // TODO: Reuse `stmt`s for listing trackers and queueing emails to reduce overheads + foreach ($actual_deletions as $deletion) + foreach ($this->tracking_manager->list_trackers($deletion) as $user_email) + $this->mailer->queue_email(new NotifyArticleDeletedEmail($user_email, $deletion)); + + foreach ($undeletions as $undeletion) + foreach ($this->tracking_manager->list_trackers($undeletion) as $user_email) + $this->mailer->queue_email(new NotifyArticleUndeletedEmail($user_email, $undeletion)); + + foreach ($status_changes as $person_name => $person_status) + foreach ($this->tracking_manager->list_trackers($person_name) as $user_email) + $this->mailer->queue_email(new NotifyStatusChangedEmail($user_email, $person_name, $person_status)); return null; } diff --git a/src/main/php/com/fwdekker/deathnotifier/user/ChangedEmailEmail.php b/src/main/php/com/fwdekker/deathnotifier/user/ChangedEmailEmail.php index ca7b828..2606a68 100644 --- a/src/main/php/com/fwdekker/deathnotifier/user/ChangedEmailEmail.php +++ b/src/main/php/com/fwdekker/deathnotifier/user/ChangedEmailEmail.php @@ -16,7 +16,7 @@ class ChangedEmailEmail extends Email public const TYPE = "changed-email"; /** - * @var string The token to verify the email address with. + * @var string the token to verify the email address with */ public string $token; diff --git a/src/main/php/com/fwdekker/deathnotifier/user/RegisterEmail.php b/src/main/php/com/fwdekker/deathnotifier/user/RegisterEmail.php index ea8a055..e50e57a 100644 --- a/src/main/php/com/fwdekker/deathnotifier/user/RegisterEmail.php +++ b/src/main/php/com/fwdekker/deathnotifier/user/RegisterEmail.php @@ -16,7 +16,7 @@ class RegisterEmail extends Email public const TYPE = "register"; /** - * @var string The token to verify the email address with. + * @var string the token to verify the email address with */ public string $token; diff --git a/src/main/php/com/fwdekker/deathnotifier/user/ResetPasswordEmail.php b/src/main/php/com/fwdekker/deathnotifier/user/ResetPasswordEmail.php index 8666ae6..c163129 100644 --- a/src/main/php/com/fwdekker/deathnotifier/user/ResetPasswordEmail.php +++ b/src/main/php/com/fwdekker/deathnotifier/user/ResetPasswordEmail.php @@ -16,7 +16,7 @@ class ResetPasswordEmail extends Email public const TYPE = "reset-password"; /** - * @var string The token to reset the password with. + * @var string the token to reset the password with */ public string $token; diff --git a/src/main/php/com/fwdekker/deathnotifier/user/UserManager.php b/src/main/php/com/fwdekker/deathnotifier/user/UserManager.php index cb33caf..df01d9e 100644 --- a/src/main/php/com/fwdekker/deathnotifier/user/UserManager.php +++ b/src/main/php/com/fwdekker/deathnotifier/user/UserManager.php @@ -41,15 +41,15 @@ class UserManager /** - * @var Logger The logger to use for logging. + * @var Logger the logger to use for logging */ private Logger $logger; // @phpstan-ignore-line Unused, but useful for debugging /** - * @var PDO The database connection to interact with. + * @var PDO the database connection to interact with */ private PDO $conn; /** - * @var Mailer The mailer to send emails with. + * @var Mailer the mailer to send emails with */ private Mailer $mailer; diff --git a/src/main/php/com/fwdekker/deathnotifier/user/VerifyEmailEmail.php b/src/main/php/com/fwdekker/deathnotifier/user/VerifyEmailEmail.php index 3bcb2c2..37d0056 100644 --- a/src/main/php/com/fwdekker/deathnotifier/user/VerifyEmailEmail.php +++ b/src/main/php/com/fwdekker/deathnotifier/user/VerifyEmailEmail.php @@ -16,7 +16,7 @@ class VerifyEmailEmail extends Email public const TYPE = "verify-email"; /** - * @var string The token to verify the email address with. + * @var string the token to verify the email address with */ public string $token; diff --git a/src/main/php/com/fwdekker/deathnotifier/validator/HasLengthRule.php b/src/main/php/com/fwdekker/deathnotifier/validator/HasLengthRule.php index 47d1dbd..cf484cd 100644 --- a/src/main/php/com/fwdekker/deathnotifier/validator/HasLengthRule.php +++ b/src/main/php/com/fwdekker/deathnotifier/validator/HasLengthRule.php @@ -11,17 +11,17 @@ use com\fwdekker\deathnotifier\ValidationException; class HasLengthRule extends Rule { /** - * @var int|null The minimum length (inclusive), or `null` if there is no minimum length. + * @var int|null the minimum length (inclusive), or `null` if there is no minimum length */ private readonly ?int $min_length; /** - * @var int|null The maximum length (inclusive), or `null` if there is no maximum length. + * @var int|null the maximum length (inclusive), or `null` if there is no maximum length */ private readonly ?int $max_length; /** - * Verifies that the input is of the specific length. + * Constructs a new `HasLengthRule`. * * @param int|null $min_length the minimum length (inclusive), or `null` if there is no minimum length * @param int|null $max_length the maximum length (inclusive), or `null` if there is no maximum length diff --git a/src/main/php/com/fwdekker/deathnotifier/validator/IsEqualToRule.php b/src/main/php/com/fwdekker/deathnotifier/validator/IsEqualToRule.php index b2e141f..2af7508 100644 --- a/src/main/php/com/fwdekker/deathnotifier/validator/IsEqualToRule.php +++ b/src/main/php/com/fwdekker/deathnotifier/validator/IsEqualToRule.php @@ -17,7 +17,7 @@ class IsEqualToRule extends Rule /** - * Constructs an `IsEqualToRule` that checks for equality with the specified value. + * Constructs a new `IsEqualToRule`. * * @param string $expected the value that checked values should be equal to */ diff --git a/src/main/php/com/fwdekker/deathnotifier/validator/Rule.php b/src/main/php/com/fwdekker/deathnotifier/validator/Rule.php index e590adf..098195b 100644 --- a/src/main/php/com/fwdekker/deathnotifier/validator/Rule.php +++ b/src/main/php/com/fwdekker/deathnotifier/validator/Rule.php @@ -12,7 +12,7 @@ use com\fwdekker\deathnotifier\ValidationException; abstract class Rule { /** - * @var string|null The message to return if the rule does not apply to some input. If `null`, the rule + * @var string|null the message to return if the rule does not apply to some input. If `null`, the rule * implementation can choose an appropriate message. */ public ?string $override_message;