Move tracking processing logic to Action classes

This commit is contained in:
Florine W. Dekker 2022-12-01 22:24:20 +01:00
parent 18a2d0d779
commit ca30d9d42c
Signed by: FWDekker
GPG Key ID: D3DCFAA8A4560BE0
32 changed files with 459 additions and 272 deletions

View File

@ -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",

BIN
composer.lock generated

Binary file not shown.

BIN
package-lock.json generated

Binary file not shown.

View File

@ -1,6 +1,6 @@
{
"name": "death-notifier",
"version": "0.15.3", "_comment_version": "Also update version in `composer.json`!",
"version": "0.15.4", "_comment_version": "Also update version in `composer.json`!",
"description": "Get notified when a famous person dies.",
"author": "Florine W. Dekker",
"browser": "dist/bundle.js",

View File

@ -6,7 +6,7 @@ use com\fwdekker\deathnotifier\Database;
use com\fwdekker\deathnotifier\EmulateCronCliAction;
use com\fwdekker\deathnotifier\mailer\Mailer;
use com\fwdekker\deathnotifier\mailer\ProcessEmailQueueCliAction;
use com\fwdekker\deathnotifier\mediawiki\Mediawiki;
use com\fwdekker\deathnotifier\mediawiki\MediaWiki;
use com\fwdekker\deathnotifier\Response;
use com\fwdekker\deathnotifier\StartSessionAction;
use com\fwdekker\deathnotifier\tracking\AddTrackingAction;
@ -41,9 +41,9 @@ $logger = Util::create_logger($config["logger"]);
$db = new Database($logger->withName("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]);

View File

@ -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;

View File

@ -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)

View File

@ -0,0 +1,10 @@
<?php
/**
* Thrown if something happens that should not be able to happen, and there is no way to recover.
*/
class InvalidStateException extends Error
{
// Intentionally left empty
}

View File

@ -9,11 +9,11 @@ namespace com\fwdekker\deathnotifier;
class Response
{
/**
* @var mixed The payload corresponding to the client's query.
* @var mixed the payload corresponding to the client's query
*/
public mixed $payload;
/**
* @var bool `true` if and only if the request was fully completed.
* @var bool `true` if and only if the request was fully completed
*/
public bool $satisfied;

View File

@ -12,7 +12,7 @@ use Exception;
class StartSessionAction extends Action
{
/**
* @var array the application's configuration
* @var array<string, mixed> 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<string, mixed> $config the application's configuration
* @param UserManager $user_manager the manager to validate the session through
*/
public function __construct(array $config, UserManager $user_manager)

View File

@ -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 = "";

View File

@ -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<string, array<string, mixed>> The configuration to use for mailing.
* @var array<string, array<string, mixed>> 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;

View File

@ -0,0 +1,91 @@
<?php
namespace com\fwdekker\deathnotifier\mediawiki;
use com\fwdekker\deathnotifier\ValidationException;
use com\fwdekker\deathnotifier\validator\Rule;
/**
* Verifies that the input refers to a page about a person on Wikipedia.
*/
class IsPersonPageRule extends Rule
{
/**
* @var MediaWiki the instance to connect to Wikipedia with
*/
private readonly MediaWiki $mediawiki;
/**
* Constructs a new `IsPersonPageRule`.
*
* @param MediaWiki $mediawiki the instance to connect to Wikipedia with
* @param string|null $override_message the message to return if the rule does not apply to some input. If `null`,
* the rule implementation can choose an appropriate message
*/
public function __construct(MediaWiki $mediawiki, ?string $override_message = null)
{
parent::__construct($override_message);
$this->mediawiki = $mediawiki;
}
/**
* Verifies that the input refers to a page about a person on Wikipedia.
*
* @param array<int|string, mixed> $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 " .
"<b><a href='https://en.wikipedia.org/wiki/Special:Search?search=" .
rawurlencode($normalized_name) . "'>" . htmlentities($person_name) . "</a></b>. " .
"Maybe you need to capitalise the surname?",
"person_name"
);
if ($type === ArticleType::Disambiguation)
throw new ValidationException(
$this->override_message ??
"<b><a href='https://en.wikipedia.org/wiki/" . rawurlencode($normalized_name) . "'>" .
htmlentities($normalized_name) . "</a></b> refers to multiple articles. " .
"<a href='https://en.wikipedia.org/wiki/" . rawurlencode($normalized_name) . "'>Check Wikipedia</a> " .
"to see if your article is listed.",
"person_name"
);
if ($type === ArticleType::Other)
throw new ValidationException(
$this->override_message ??
"The Wikipedia article about " .
"<b><a href='https://en.wikipedia.org/wiki/" . rawurlencode($normalized_name) . "'>" .
htmlentities($normalized_name) . "</a></b> is not about a real-world person.",
"person_name"
);
}
}

View File

@ -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<string, mixed> $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<mixed> 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<array{"status": PersonStatus|null, "type": ArticleType}> 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<array{"type": ArticleType, "status": PersonStatus|null}> 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
)

View File

@ -0,0 +1,13 @@
<?php
namespace com\fwdekker\deathnotifier\mediawiki;
use Exception;
/**
* Thrown if something goes wrong while interacting with the MediaWiki API.
*/
class MediaWikiException extends Exception {
// Empty
}

View File

@ -1,6 +1,6 @@
<?php
namespace com\fwdekker\deathnotifier\tracking;
namespace com\fwdekker\deathnotifier\mediawiki;
/**

View File

@ -5,8 +5,16 @@ namespace com\fwdekker\deathnotifier\tracking;
use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionException;
use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\mediawiki\ArticleType;
use com\fwdekker\deathnotifier\mediawiki\IsPersonPageRule;
use com\fwdekker\deathnotifier\mediawiki\MediaWiki;
use com\fwdekker\deathnotifier\mediawiki\MediaWikiException;
use com\fwdekker\deathnotifier\Response;
use com\fwdekker\deathnotifier\ValidationException;
use com\fwdekker\deathnotifier\validator\HasLengthRule;
use com\fwdekker\deathnotifier\validator\IsNotBlankRule;
use Exception;
use InvalidStateException;
/**
@ -14,15 +22,23 @@ use com\fwdekker\deathnotifier\validator\IsNotBlankRule;
*/
class AddTrackingAction extends Action
{
/**
* @var TrackingManager the manager to add the tracking to
*/
private readonly TrackingManager $tracking_manager;
/**
* @var MediaWiki the instance to connect to Wikipedia with
*/
private readonly MediaWiki $mediawiki;
/**
* Constructs a new `AddTrackingAction`.
*
* @param TrackingManager $tracking_manager the manager to add the tracking to
* @param MediaWiki $mediawiki the instance to connect to Wikipedia with
*/
public function __construct(TrackingManager $tracking_manager)
public function __construct(TrackingManager $tracking_manager, MediaWiki $mediawiki)
{
parent::__construct(
ActionMethod::POST,
@ -32,21 +48,54 @@ class AddTrackingAction extends Action
rule_lists: [
"person_name" => [
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 <b>$normalized_name</b>.");
$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)
];
}
}

View File

@ -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"]);
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

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

View File

@ -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 " .
"<b><a href='https://en.wikipedia.org/wiki/Special:Search?search=" .
rawurlencode($normalized_name) . "'>" . htmlentities($person_name) . "</a></b>. " .
"Maybe you need to capitalise the surname?",
"person_name"
);
if ($type === ArticleType::Disambiguation)
return Response::unsatisfied(
"<b><a href='https://en.wikipedia.org/wiki/" . rawurlencode($normalized_name) . "'>" .
htmlentities($normalized_name) . "</a></b> refers to multiple articles. " .
"<a href='https://en.wikipedia.org/wiki/" . rawurlencode($normalized_name) . "'>Check Wikipedia</a> " .
"to see if your article is listed.",
"person_name"
);
if ($type === ArticleType::Other)
return Response::unsatisfied(
"The Wikipedia article about " .
"<b><a href='https://en.wikipedia.org/wiki/" . rawurlencode($normalized_name) . "'>" .
htmlentities($normalized_name) . "</a></b> 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 <b>" . htmlentities($normalized_name) . "</b>.",
"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<array{"name": string, "status": string, "is_deleted": bool}> 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<string, string> $redirects a map of all changes, from old name to new name
* @param array<string, string> $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<string, array{"status": PersonStatus|null, "type": ArticleType}> $statuses the current statuses of
* @param array<string, array{"type": ArticleType, "status": PersonStatus|null}> $statuses the current statuses of
* people
* @return void
* @return array{"undeletions": string[], "status_changes": array<string, string>} 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];
}
}

View File

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

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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
*/

View File

@ -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;