Add "alive again" notifications and similar

This commit is contained in:
Florine W. Dekker 2022-11-16 21:33:04 +01:00
parent 6669b62d66
commit 2fe92e8d2e
Signed by: FWDekker
GPG Key ID: D3DCFAA8A4560BE0
11 changed files with 289 additions and 109 deletions

View File

@ -1,7 +1,7 @@
{ {
"name": "fwdekker/death-notifier", "name": "fwdekker/death-notifier",
"description": "Get notified when a famous person dies.", "description": "Get notified when a famous person dies.",
"version": "0.9.2", "_comment_version": "Also update version in `package.json`!", "version": "0.10.0", "_comment_version": "Also update version in `package.json`!",
"type": "project", "type": "project",
"license": "MIT", "license": "MIT",
"homepage": "https://git.fwdekker.com/tools/death-notifier", "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", "name": "death-notifier",
"version": "0.9.2", "_comment_version": "Also update version in `composer.json`!", "version": "0.10.0", "_comment_version": "Also update version in `composer.json`!",
"description": "Get notified when a famous person dies.", "description": "Get notified when a famous person dies.",
"author": "Florine W. Dekker", "author": "Florine W. Dekker",
"browser": "dist/bundle.js", "browser": "dist/bundle.js",

View File

@ -37,7 +37,6 @@
<div class="column"> <div class="column">
<p id="globalMessage" class="formValidationInfo"> <p id="globalMessage" class="formValidationInfo">
<output class="validationInfo" for="globalMessage"></output> <output class="validationInfo" for="globalMessage"></output>
<button type="button" class="closeButton">&times;</button>
</p> </p>
<p id="sharedValidationInfo" class="formValidationInfo"> <p id="sharedValidationInfo" class="formValidationInfo">
<output class="validationInfo" for="sharedValidationInfo"></output> <output class="validationInfo" for="sharedValidationInfo"></output>

View File

@ -45,14 +45,14 @@ function refreshTrackings(): void {
const statusCell = document.createElement("td"); const statusCell = document.createElement("td");
let statusText; let statusText;
if (tracking.deleted) { if (tracking.deleted) {
statusText = "page not found"; statusText = "article not found";
} else { } else {
switch (tracking.name) { switch (tracking.name) {
case "Adolf Hitler": case "Adolf Hitler":
statusText = "deceased 🥳"; statusText = "dead 🥳";
break; break;
case "Vladimir Putin": case "Vladimir Putin":
statusText = tracking.status === "alive" ? "alive ☹️" : "deceased 🥳"; statusText = tracking.status === "alive" ? "alive ☹️" : "dead 🥳";
break; break;
default: default:
statusText = tracking.status; statusText = tracking.status;

View File

@ -102,6 +102,7 @@ class Database
// Execute migration code // Execute migration code
if (Comparator::lessThan($db_version, "0.5.0")) self::migrate_0_5_0(); if (Comparator::lessThan($db_version, "0.5.0")) self::migrate_0_5_0();
if (Comparator::lessThan($db_version, "0.8.0")) self::migrate_0_8_0(); if (Comparator::lessThan($db_version, "0.8.0")) self::migrate_0_8_0();
if (Comparator::lessThan($db_version, "0.10.0")) self::migrate_0_10_0();
// Update version // Update version
$stmt = $this->conn->prepare("UPDATE meta SET v=:version WHERE k='version';"); $stmt = $this->conn->prepare("UPDATE meta SET v=:version WHERE k='version';");
@ -134,7 +135,7 @@ class Database
} }
/** /**
* Migrates the database from a previous version to one compatible with v0.5.0. * Migrates the database from a previous version to one compatible with v0.8.0.
* *
* @return void * @return void
* @noinspection SqlResolve Function necessarily refers to old scheme which is not detected by tools * @noinspection SqlResolve Function necessarily refers to old scheme which is not detected by tools
@ -161,6 +162,38 @@ class Database
} }
} }
/**
* Migrates the database from a previous version to one compatible with v0.10.0.
*
* @return void
* @noinspection SqlResolve Function necessarily refers to old scheme which is not detected by tools
*/
private function migrate_0_10_0(): void
{
$this->logger->info("Migrating to v0.10.0.");
$res =
$this->conn->exec("CREATE TABLE new_email_tasks(type TEXT NOT NULL,
recipient TEXT NOT NULL,
arg1 TEXT NOT NULL DEFAULT(''),
arg2 TEXT NOT NULL DEFAULT(''),
PRIMARY KEY (type, recipient, arg1, arg2));") !== false &&
$this->conn->exec("INSERT INTO new_email_tasks (type, recipient, arg1)
SELECT type, recipient, arg1
FROM email_tasks;") !== false &&
$this->conn->exec("DROP TABLE email_tasks;") !== false &&
$this->conn->exec("ALTER TABLE new_email_tasks RENAME TO email_tasks;") !== false &&
$this->conn->exec("UPDATE email_tasks
SET type='notify-status-changed' AND arg2='dead'
WHERE type='notify-death'") !== false;
if (!$res) {
$this->logger->error("Failed migrating to v0.10.0.", ["error" => $this->conn->errorInfo()]);
$this->conn->rollBack();
Util::http_exit(500);
}
}
/** /**
* Executes `lambda` within a single transaction, allowing nesting. * Executes `lambda` within a single transaction, allowing nesting.

View File

@ -52,8 +52,9 @@ class Mailer
{ {
$this->conn->exec("CREATE TABLE email_tasks(type TEXT NOT NULL, $this->conn->exec("CREATE TABLE email_tasks(type TEXT NOT NULL,
recipient TEXT NOT NULL, recipient TEXT NOT NULL,
arg1 TEXT DEFAULT(NULL), arg1 TEXT NOT NULL DEFAULT(''),
PRIMARY KEY (type, recipient, arg1));"); arg2 TEXT NOT NULL DEFAULT(''),
PRIMARY KEY (type, recipient, arg1, arg2));");
} }
@ -65,11 +66,12 @@ class Mailer
*/ */
public function queue_email(Email $email): Response public function queue_email(Email $email): Response
{ {
$stmt = $this->conn->prepare("INSERT OR IGNORE INTO email_tasks (type, recipient, arg1) $stmt = $this->conn->prepare("INSERT OR IGNORE INTO email_tasks (type, recipient, arg1, arg2)
VALUES (:type, :recipient, :arg1);"); VALUES (:type, :recipient, :arg1, :arg2);");
$stmt->bindValue(":type", $email->type); $stmt->bindValue(":type", $email->type);
$stmt->bindValue(":recipient", $email->recipient); $stmt->bindValue(":recipient", $email->recipient);
$stmt->bindValue(":arg1", $email->arg1); $stmt->bindValue(":arg1", $email->arg1);
$stmt->bindValue(":arg2", $email->arg2);
return $stmt->execute() ? Response::satisfied() : Response::unsatisfied(null); return $stmt->execute() ? Response::satisfied() : Response::unsatisfied(null);
} }
@ -102,19 +104,20 @@ class Mailer
} }
// Get queue // Get queue
$stmt = $this->conn->prepare("SELECT type, recipient, arg1 FROM email_tasks;"); $stmt = $this->conn->prepare("SELECT type, recipient, arg1, arg2 FROM email_tasks;");
$stmt->execute(); $stmt->execute();
$email_tasks = $stmt->fetchAll(PDO::FETCH_ASSOC); $email_tasks = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Process queue // Process queue
$stmt = $this->conn->prepare("DELETE FROM email_tasks $stmt = $this->conn->prepare("DELETE FROM email_tasks
WHERE type=:type AND recipient=:recipient AND arg1 IS :arg1;"); WHERE type=:type AND recipient=:recipient AND arg1=:arg1 AND arg2=:arg2;");
$stmt->bindParam(":type", $type); $stmt->bindParam(":type", $type);
$stmt->bindParam(":recipient", $recipient); $stmt->bindParam(":recipient", $recipient);
$stmt->bindParam(":arg1", $arg1); $stmt->bindParam(":arg1", $arg1);
foreach ($email_tasks as ["type" => $type, "recipient" => $recipient, "arg1" => $arg1]) { $stmt->bindParam(":arg2", $arg2);
foreach ($email_tasks as ["type" => $type, "recipient" => $recipient, "arg1" => $arg1, "arg2" => $arg2]) {
try { try {
$email = Email::deserialize($type, $recipient, $arg1 ?? ""); $email = Email::deserialize($type, $recipient, $arg1, $arg2);
$mailer->Subject = $email->getSubject(); $mailer->Subject = $email->getSubject();
$mailer->Body = $email->getBody($this->config); $mailer->Body = $email->getBody($this->config);
@ -161,9 +164,13 @@ abstract class Email
*/ */
public string $recipient; public string $recipient;
/** /**
* @var string|null The first argument to construct the email. * @var string The first argument to construct the email.
*/ */
public ?string $arg1; public string $arg1 = "";
/**
* @var string The second argument to construct the email.
*/
public string $arg2 = "";
/** /**
@ -190,10 +197,11 @@ abstract class Email
* @param string $type the type of email to deserialize * @param string $type the type of email to deserialize
* @param string $recipient the intended recipient of the email * @param string $recipient the intended recipient of the email
* @param string $arg1 the first argument to construct the email * @param string $arg1 the first argument to construct the email
* @param string $arg2 the second argument to construct the email
* @return Email a deserialized email * @return Email a deserialized email
* @throws \Exception if the `type` is not recognized * @throws \Exception if the `type` is not recognized
*/ */
public static function deserialize(string $type, string $recipient, string $arg1): Email public static function deserialize(string $type, string $recipient, string $arg1, string $arg2): Email
{ {
return match ($type) { return match ($type) {
RegisterEmail::TYPE => new RegisterEmail($recipient, $arg1), RegisterEmail::TYPE => new RegisterEmail($recipient, $arg1),
@ -201,7 +209,9 @@ abstract class Email
ChangedEmailEmail::TYPE => new ChangedEmailEmail($recipient, $arg1), ChangedEmailEmail::TYPE => new ChangedEmailEmail($recipient, $arg1),
ChangedPasswordEmail::TYPE => new ChangedPasswordEmail($recipient), ChangedPasswordEmail::TYPE => new ChangedPasswordEmail($recipient),
ResetPasswordEmail::TYPE => new ResetPasswordEmail($recipient, $arg1), ResetPasswordEmail::TYPE => new ResetPasswordEmail($recipient, $arg1),
NotifyDeathEmail::TYPE => new NotifyDeathEmail($recipient, $arg1), NotifyArticleDeletedEmail::TYPE => new NotifyArticleDeletedEmail($recipient, $arg1),
NotifyArticleUndeletedEmail::TYPE => new NotifyArticleUndeletedEmail($recipient, $arg1),
NotifyStatusChangedEmail::TYPE => new NotifyStatusChangedEmail($recipient, $arg1, $arg2),
default => throw new \Exception("Unknown email type $type."), default => throw new \Exception("Unknown email type $type."),
}; };
} }
@ -392,8 +402,6 @@ class ChangedPasswordEmail extends Email {
{ {
$this->type = self::TYPE; $this->type = self::TYPE;
$this->recipient = $recipient; $this->recipient = $recipient;
$this->arg1 = null;
} }
@ -473,26 +481,25 @@ class ResetPasswordEmail extends Email
} }
/** /**
* An email to inform a user someone has died. * An email to inform a user that a tracked article has been deleted.
*/ */
class NotifyDeathEmail extends Email class NotifyArticleDeletedEmail extends Email {
{
/** /**
* A string identifying the type of email. * A string identifying the type of email.
*/ */
public const TYPE = "notify-death"; public const TYPE = "notify-article-deleted";
/** /**
* @var string The name of the person who died. * @var string The name of the article that was deleted.
*/ */
public string $name; public string $name;
/** /**
* Constructs an email to inform a user someone has died. * Constructs an email to inform a user that a tracked article has been deleted.
* *
* @param string $recipient the intended recipient of the email * @param string $recipient the intended recipient of the email
* @param string $name the name of the person who died * @param string $name the name of the article that was deleted
*/ */
public function __construct(string $recipient, string $name) public function __construct(string $recipient, string $name)
{ {
@ -506,7 +513,7 @@ class NotifyDeathEmail extends Email
public function getSubject(): string public function getSubject(): string
{ {
return "$this->name may have died"; return "$this->name article has been deleted";
} }
public function getBody(array $config): string public function getBody(array $config): string
@ -514,11 +521,126 @@ class NotifyDeathEmail extends Email
$base_path = $config["server"]["base_path"]; $base_path = $config["server"]["base_path"];
return return
"Someone has edited the Wikipedia page of $this->name to state that they have died. " . "The Wikipedia article about $this->name has been deleted. " .
"For more information, read their Wikipedia page at " . "Death Notifier is now unable to send you a notification if $this->name dies. " .
"https://en.wikipedia.org/wiki/" . rawurlencode($this->name) . "If the Wikipedia article is ever re-created, Death Notifier will automatically resume tracking this " .
"article, and you will receive another notification." .
"\n\n" . "\n\n" .
"You are receiving this message because of your preferences in your Death Notifier account. " . "You are receiving this message because of the preferences in your Death Notifier account. " .
"To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " .
"preferences." .
"\n\n" .
$base_path;
}
}
/**
* An email to inform a user that a tracked article has been re-created.
*/
class NotifyArticleUndeletedEmail extends Email {
/**
* A string identifying the type of email.
*/
public const TYPE = "notify-article-undeleted";
/**
* @var string The name of the article that was re-created.
*/
public string $name;
/**
* Constructs an email to inform a user that a tracked article has been re-created.
*
* @param string $recipient the intended recipient of the email
* @param string $name the name of the article that was re-created
*/
public function __construct(string $recipient, string $name)
{
$this->type = self::TYPE;
$this->recipient = $recipient;
$this->name = $name;
$this->arg1 = $name;
}
public function getSubject(): string
{
return "$this->name article has been re-created";
}
public function getBody(array $config): string
{
$base_path = $config["server"]["base_path"];
return
"The Wikipedia article about $this->name has been re-created. " .
"Death Notifier will once again track the article and notify you if $this->name dies." .
"\n\n" .
"You are receiving this message because of the preferences in your Death Notifier account. " .
"To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " .
"preferences." .
"\n\n" .
$base_path;
}
}
/**
* An email to inform a user a tracker person's status has changed.
*/
class NotifyStatusChangedEmail extends Email
{
/**
* A string identifying the type of email.
*/
public const TYPE = "notify-status-changed";
/**
* @var string The name of the person whose status has changed.
*/
public string $name;
/**
* @var string The new status of the person.
*/
public string $new_status;
/**
* Constructs an email to inform a user someone has died.
*
* @param string $recipient the intended recipient of the email
* @param string $name the name of the person who died
* @param string $new_status the new status of the person
*/
public function __construct(string $recipient, string $name, string $new_status)
{
$this->type = self::TYPE;
$this->recipient = $recipient;
$this->name = $name;
$this->arg1 = $name;
$this->new_status = $new_status;
$this->arg2 = $new_status;
}
public function getSubject(): string
{
return "$this->name may be $this->new_status";
}
public function getBody(array $config): string
{
$base_path = $config["server"]["base_path"];
return
"Someone has edited Wikipedia to state that $this->name is $this->new_status. " .
"For more information, read their Wikipedia article at " .
"https://en.wikipedia.org/wiki/" . rawurlencode($this->name) .
"\n\n" .
"You are receiving this message because of the preferences in your Death Notifier account. " .
"To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " . "To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " .
"preferences." . "preferences." .
"\n\n" . "\n\n" .

View File

@ -22,9 +22,10 @@ class Mediawiki
"death-notifier/%%VERSION_NUMBER%% " . "death-notifier/%%VERSION_NUMBER%% " .
"(https://git.fwdekker.com/tools/death-notifier; florine@fwdekker.com)"; "(https://git.fwdekker.com/tools/death-notifier; florine@fwdekker.com)";
/** /**
* Number of categories to return per page per query. * Number of categories to return per article per query.
* *
* Since the record for a single page is 252 categories, setting this to the maximum of 500 is more than sufficient. * Since the record for a single article is 252 categories, setting this to the maximum of 500 is more than
* sufficient.
*/ */
private const CATS_PER_QUERY = 500; private const CATS_PER_QUERY = 500;
@ -76,7 +77,7 @@ class Mediawiki
*/ */
private function api_query(array $params, array $titles): QueryOutput private function api_query(array $params, array $titles): QueryOutput
{ {
$pages = []; $articles = [];
$redirects = array_combine($titles, $titles); $redirects = array_combine($titles, $titles);
$missing = []; $missing = [];
@ -103,11 +104,11 @@ class Mediawiki
exit(); exit();
} }
foreach ($response["pages"] as $page_id => $page) { foreach ($response["pages"] as $article_id => $article) {
if ($page_id < 0) if ($article_id < 0)
$missing[] = strval($page["title"]); $missing[] = strval($article["title"]);
else else
$pages[strval($page["title"])] = $page; $articles[strval($article["title"])] = $article;
} }
$response_normalized = array_column($response["normalized"] ?? [], "to", "from"); $response_normalized = array_column($response["normalized"] ?? [], "to", "from");
@ -121,38 +122,38 @@ class Mediawiki
} }
} }
return new QueryOutput($pages, $redirects, $missing); return new QueryOutput($articles, $redirects, $missing);
} }
/** /**
* Determines for each title whether the page at Wikipedia exists. * Determines for each title whether the article at Wikipedia exists.
* *
* @param string[] $titles the titles of the pages to check * @param string[] $titles the titles of the articles to check
* @return QueryOutput<string> a query output where the result is a flat array with the non-missing pages * @return QueryOutput<string> a query output where the result is a flat array with the non-missing articles
*/ */
public function pages_exist(array $titles): QueryOutput public function articles_exist(array $titles): QueryOutput
{ {
$output = $this->api_query(["prop" => "info"], $titles); $output = $this->api_query(["prop" => "info"], $titles);
return new QueryOutput(array_fill_keys(array_keys($output->results), ""), $output->redirects, $output->missing); return new QueryOutput(array_fill_keys(array_keys($output->results), ""), $output->redirects, $output->missing);
} }
/** /**
* Returns the person's status, or `null` if the title does not refer to a page about a person on Wikipedia. * Returns the person's status, or `null` if the title does not refer to an article about a person on Wikipedia.
* *
* @param mixed $person_page the page as returned by the Wikipedia API * @param mixed $article the article object as returned by the Wikipedia API
* @return PersonStatus|null the person's status, or `null` if the title does not refer to a page about a person on * @return PersonStatus|null the person's status, or `null` if the title does not refer to an article about a person
* Wikipedia * on Wikipedia
*/ */
private function person_status(mixed $person_page): ?PersonStatus private function person_status(mixed $article): ?PersonStatus
{ {
if (array_key_exists("missing", $person_page) || array_key_exists("invalid", $person_page)) if (array_key_exists("missing", $article) || array_key_exists("invalid", $article))
return null; return null;
$category_titles = array_column($person_page["categories"], "title"); $category_titles = array_column($article["categories"], "title");
$deceased_regex = "/^Category:([0-9]{1,4}s? (BC |AD )?deaths|Year of death (missing|unknown))$/"; $dead_regex = "/^Category:([0-9]{1,4}s? (BC |AD )?deaths|Year of death (missing|unknown))$/";
if (!empty(array_filter($category_titles, fn($it) => preg_match($deceased_regex, $it)))) if (!empty(array_filter($category_titles, fn($it) => preg_match($dead_regex, $it))))
return PersonStatus::Deceased; return PersonStatus::Dead;
elseif (in_array("Category:Possibly living people", $category_titles)) elseif (in_array("Category:Possibly living people", $category_titles))
return PersonStatus::PossiblyAlive; return PersonStatus::PossiblyAlive;
elseif (in_array("Category:Missing people", $category_titles)) elseif (in_array("Category:Missing people", $category_titles))
@ -166,20 +167,20 @@ class Mediawiki
/** /**
* Checks for each person what their status is according to Wikipedia's categorization. * Checks for each person what their status is according to Wikipedia's categorization.
* *
* @param array<string> $people_names the names of the people to check aliveness of * @param array<string> $names the names of the people to check aliveness of
* @return QueryOutput<PersonStatus|null> a query output with a response indicating for each person the status * @return QueryOutput<PersonStatus|null> a query output with a response indicating for each person the status
*/ */
public function people_statuses(array $people_names): QueryOutput public function query_statuses(array $names): QueryOutput
{ {
$output = $this->api_query(["prop" => "categories", "cllimit" => strval(self::CATS_PER_QUERY)], $people_names); $output = $this->api_query(["prop" => "categories", "cllimit" => strval(self::CATS_PER_QUERY)], $names);
$pages = $articles =
array_combine( array_combine(
array_column($output->results, "title"), array_column($output->results, "title"),
array_map(fn($it) => $this->person_status($it), $output->results) array_map(fn($it) => $this->person_status($it), $output->results)
); );
return new QueryOutput($pages, $output->redirects, $output->missing); return new QueryOutput($articles, $output->redirects, $output->missing);
} }
} }
@ -200,7 +201,7 @@ class QueryOutput
*/ */
public readonly array $redirects; public readonly array $redirects;
/** /**
* @var string[] list of missing pages * @var string[] list of missing articles
*/ */
public readonly array $missing; public readonly array $missing;
@ -210,7 +211,7 @@ class QueryOutput
* *
* @param array<string, T> $results the results of the query, either raw from the API or processed in some way * @param array<string, T> $results the results of the query, either raw from the API or processed in some way
* @param array<string, string> $redirects mapping of queried names to normalized/redirected names * @param array<string, string> $redirects mapping of queried names to normalized/redirected names
* @param string[] $missing list of missing pages * @param string[] $missing list of missing articles
*/ */
public function __construct(array $results, array $redirects, array $missing) public function __construct(array $results, array $redirects, array $missing)
{ {

View File

@ -12,11 +12,11 @@ use PDO;
class TrackingManager class TrackingManager
{ {
/** /**
* The minimum length of a Wikipedia page title. * The minimum length of a Wikipedia article title.
*/ */
public const MIN_TITLE_LENGTH = 1; public const MIN_TITLE_LENGTH = 1;
/** /**
* The maximum length of a Wikipedia page title. * The maximum length of a Wikipedia article title.
*/ */
public const MAX_TITLE_LENGTH = 255; public const MAX_TITLE_LENGTH = 255;
@ -96,21 +96,21 @@ class TrackingManager
public function add_tracking(string $user_uuid, string $person_name): Response public function add_tracking(string $user_uuid, string $person_name): Response
{ {
// TODO: Reject if person is already tracked // TODO: Reject if person is already tracked
// TODO: Show link to Wikipedia page to search if page does not exist // TODO: Show link to Wikipedia page to search if article does not exist
// Validate that page exists // Validate that article exists
$pages_exist = $this->mediawiki->pages_exist([$person_name]); $article_exists = $this->mediawiki->articles_exist([$person_name]);
$normalized_name = $pages_exist->redirects[$person_name]; $normalized_name = $article_exists->redirects[$person_name];
if (in_array($normalized_name, $pages_exist->missing)) if (in_array($normalized_name, $article_exists->missing))
return Response::unsatisfied("Page does not exist.", "person_name"); return Response::unsatisfied("Article does not exist.", "person_name");
$statuses = $this->mediawiki->people_statuses([$normalized_name])->results; $statuses = $this->mediawiki->query_statuses([$normalized_name])->results;
if (!isset($statuses[$normalized_name])) if (!isset($statuses[$normalized_name]))
return Response::unsatisfied("Page does not refer to a person.", "person_name"); return Response::unsatisfied("Article does not refer to a person.", "person_name");
$status = $statuses[$normalized_name]; $status = $statuses[$normalized_name];
// Insert person and tracking // Insert person and tracking
return Database::transaction($this->conn, function() use ($user_uuid, $normalized_name, $status) { return Database::transaction($this->conn, function () use ($user_uuid, $normalized_name, $status) {
$stmt = $this->conn->prepare("INSERT OR IGNORE INTO people (name) VALUES (:name);"); $stmt = $this->conn->prepare("INSERT OR IGNORE INTO people (name) VALUES (:name);");
$stmt->bindValue(":name", $normalized_name); $stmt->bindValue(":name", $normalized_name);
$stmt->execute(); $stmt->execute();
@ -166,6 +166,26 @@ class TrackingManager
return Response::satisfied($results); return Response::satisfied($results);
} }
/**
* Returns the email addresses of all users who should be notified of events relating to the given person.
*
* @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
{
$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");
}
/** /**
* Lists all unique names being tracked in the database. * Lists all unique names being tracked in the database.
@ -182,14 +202,14 @@ class TrackingManager
/** /**
* Updates trackings for the given names. * Updates trackings for the given names.
* *
* @param string[] $people_names the names of the pages to update * @param string[] $names the names of the trackings to update
* @return void * @return void
*/ */
public function update_trackings(array $people_names): void public function update_trackings(array $names): void
{ {
if (empty($people_names)) return; if (empty($names)) return;
$people_statuses = $this->mediawiki->people_statuses($people_names); $people_statuses = $this->mediawiki->query_statuses($names);
Database::transaction($this->conn, function () use ($people_statuses) { Database::transaction($this->conn, function () use ($people_statuses) {
$this->process_redirects($people_statuses->redirects); $this->process_redirects($people_statuses->redirects);
@ -199,7 +219,7 @@ class TrackingManager
} }
/** /**
* Renames pages. * 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> $redirects a map of all changes, from old name to new name
* @return void * @return void
@ -240,18 +260,31 @@ class TrackingManager
} }
/** /**
* Deletes pages. * Deletes people from the database.
* *
* @param string[] $deletions list of pages to delete from the database * @param string[] $deletions list of names of people to remove from the database
* @return void * @return void
*/ */
private function process_deletions(array $deletions): void private function process_deletions(array $deletions): void
{ {
$stmt = $this->conn->prepare("UPDATE people SET is_deleted=1 WHERE name=:name;"); // Query to delete person, returning `name` to determine whether something changed
$stmt->bindParam(":name", $deleted_name); $delete = $this->conn->prepare("UPDATE people
foreach ($deletions as $deleted_name) SET is_deleted=1
// TODO: Inform user that page has been deleted WHERE name=:name AND is_deleted<>1
$stmt->execute(); RETURNING name;");
$delete->bindParam(":name", $deleted_name);
foreach ($deletions as $deleted_name) {
$delete->execute();
$deleted = sizeof($delete->fetchAll(PDO::FETCH_ASSOC)) > 0;
if ($deleted) {
$this->logger->info("deleting", [$deleted_name]);
foreach ($this->get_recipients($deleted_name) as $user_email) {
$this->logger->info("sending to", [$user_email]);
$this->mailer->queue_email(new NotifyArticleDeletedEmail($user_email, $deleted_name));
}
}
}
} }
/** /**
@ -262,8 +295,12 @@ class TrackingManager
*/ */
private function process_statuses(array $statuses): void private function process_statuses(array $statuses): void
{ {
// Query to mark person as no longer deleted // TODO: Restrict number of notifications to 1 per hour (excluding "oops we're not sure" message)
$undelete = $this->conn->prepare("UPDATE people SET is_deleted=0 WHERE name=:name;"); // 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); $undelete->bindParam(":name", $person_name);
// Query to update status, returning `name` to determine whether something changed // Query to update status, returning `name` to determine whether something changed
@ -274,33 +311,21 @@ class TrackingManager
$set_status->bindParam(":status", $person_status); $set_status->bindParam(":status", $person_status);
$set_status->bindParam(":name", $person_name); $set_status->bindParam(":name", $person_name);
// Query to determine which users to notify
$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);
foreach ($statuses as $person_name => $person_status_enum) { foreach ($statuses as $person_name => $person_status_enum) {
if ($person_status_enum === null) continue; if ($person_status_enum === null) continue;
$person_status = $person_status_enum->value; $person_status = $person_status_enum->value;
$undelete->execute(); $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));
$set_status->execute(); $set_status->execute();
$status_changed = sizeof($set_status->fetchAll(PDO::FETCH_ASSOC)) > 0; $status_changed = sizeof($set_status->fetchAll(PDO::FETCH_ASSOC)) > 0;
if ($status_changed)
if ($status_changed) { foreach ($this->get_recipients($person_name) as $user_email)
$get_recipients->execute(); $this->mailer->queue_email(new NotifyStatusChangedEmail($user_email, $person_name, $person_status));
$recipients = array_column($get_recipients->fetchAll(PDO::FETCH_ASSOC), "email");
// TODO: Write "alive again" notification
if ($person_status_enum === PersonStatus::Deceased)
foreach ($recipients as $user_email)
$this->mailer->queue_email(new NotifyDeathEmail($user_email, $person_name));
}
} }
} }
} }
@ -314,5 +339,5 @@ enum PersonStatus: string
case Alive = "alive"; case Alive = "alive";
case PossiblyAlive = "possibly alive"; case PossiblyAlive = "possibly alive";
case Missing = "missing"; case Missing = "missing";
case Deceased = "deceased"; case Dead = "dead";
} }

View File

@ -125,7 +125,7 @@ class IsEmailRule extends Rule
public function check(array $inputs, string $key): ?Response public function check(array $inputs, string $key): ?Response
{ {
return !isset($inputs[$key]) || !filter_var($inputs[$key], FILTER_VALIDATE_EMAIL) return !isset($inputs[$key]) || !filter_var($inputs[$key], FILTER_VALIDATE_EMAIL)
? Response::unsatisfied($this->override_message ?? "Please enter a valid email address.", $key) ? Response::unsatisfied($this->override_message ?? "Enter a valid email address.", $key)
: null; : null;
} }
} }