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",
"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",
"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.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.",
"author": "Florine W. Dekker",
"browser": "dist/bundle.js",

View File

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

View File

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

View File

@ -102,6 +102,7 @@ class Database
// Execute migration code
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.10.0")) self::migrate_0_10_0();
// Update 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
* @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.

View File

@ -52,8 +52,9 @@ class Mailer
{
$this->conn->exec("CREATE TABLE email_tasks(type TEXT NOT NULL,
recipient TEXT NOT NULL,
arg1 TEXT DEFAULT(NULL),
PRIMARY KEY (type, recipient, arg1));");
arg1 TEXT NOT NULL DEFAULT(''),
arg2 TEXT NOT NULL DEFAULT(''),
PRIMARY KEY (type, recipient, arg1, arg2));");
}
@ -65,11 +66,12 @@ class Mailer
*/
public function queue_email(Email $email): Response
{
$stmt = $this->conn->prepare("INSERT OR IGNORE INTO email_tasks (type, recipient, arg1)
VALUES (:type, :recipient, :arg1);");
$stmt = $this->conn->prepare("INSERT OR IGNORE INTO email_tasks (type, recipient, arg1, arg2)
VALUES (:type, :recipient, :arg1, :arg2);");
$stmt->bindValue(":type", $email->type);
$stmt->bindValue(":recipient", $email->recipient);
$stmt->bindValue(":arg1", $email->arg1);
$stmt->bindValue(":arg2", $email->arg2);
return $stmt->execute() ? Response::satisfied() : Response::unsatisfied(null);
}
@ -102,19 +104,20 @@ class Mailer
}
// 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();
$email_tasks = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Process queue
$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(":recipient", $recipient);
$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 {
$email = Email::deserialize($type, $recipient, $arg1 ?? "");
$email = Email::deserialize($type, $recipient, $arg1, $arg2);
$mailer->Subject = $email->getSubject();
$mailer->Body = $email->getBody($this->config);
@ -161,9 +164,13 @@ abstract class Email
*/
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 $recipient the intended recipient of 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
* @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) {
RegisterEmail::TYPE => new RegisterEmail($recipient, $arg1),
@ -201,7 +209,9 @@ abstract class Email
ChangedEmailEmail::TYPE => new ChangedEmailEmail($recipient, $arg1),
ChangedPasswordEmail::TYPE => new ChangedPasswordEmail($recipient),
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."),
};
}
@ -392,8 +402,6 @@ class ChangedPasswordEmail extends Email {
{
$this->type = self::TYPE;
$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.
*/
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;
/**
* 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 $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)
{
@ -506,7 +513,7 @@ class NotifyDeathEmail extends Email
public function getSubject(): string
{
return "$this->name may have died";
return "$this->name article has been deleted";
}
public function getBody(array $config): string
@ -514,11 +521,126 @@ class NotifyDeathEmail extends Email
$base_path = $config["server"]["base_path"];
return
"Someone has edited the Wikipedia page of $this->name to state that they have died. " .
"For more information, read their Wikipedia page at " .
"https://en.wikipedia.org/wiki/" . rawurlencode($this->name) .
"The Wikipedia article about $this->name has been deleted. " .
"Death Notifier is now unable to send you a notification if $this->name dies. " .
"If the Wikipedia article is ever re-created, Death Notifier will automatically resume tracking this " .
"article, and you will receive another notification." .
"\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 " .
"preferences." .
"\n\n" .

View File

@ -22,9 +22,10 @@ class Mediawiki
"death-notifier/%%VERSION_NUMBER%% " .
"(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;
@ -76,7 +77,7 @@ class Mediawiki
*/
private function api_query(array $params, array $titles): QueryOutput
{
$pages = [];
$articles = [];
$redirects = array_combine($titles, $titles);
$missing = [];
@ -103,11 +104,11 @@ class Mediawiki
exit();
}
foreach ($response["pages"] as $page_id => $page) {
if ($page_id < 0)
$missing[] = strval($page["title"]);
foreach ($response["pages"] as $article_id => $article) {
if ($article_id < 0)
$missing[] = strval($article["title"]);
else
$pages[strval($page["title"])] = $page;
$articles[strval($article["title"])] = $article;
}
$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
* @return QueryOutput<string> a query output where the result is a flat array with the non-missing pages
* @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 articles
*/
public function pages_exist(array $titles): QueryOutput
public function articles_exist(array $titles): QueryOutput
{
$output = $this->api_query(["prop" => "info"], $titles);
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
* @return PersonStatus|null the person's status, or `null` if the title does not refer to a page about a person on
* Wikipedia
* @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 an article about a person
* 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;
$category_titles = array_column($person_page["categories"], "title");
$deceased_regex = "/^Category:([0-9]{1,4}s? (BC |AD )?deaths|Year of death (missing|unknown))$/";
$category_titles = array_column($article["categories"], "title");
$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))))
return PersonStatus::Deceased;
if (!empty(array_filter($category_titles, fn($it) => preg_match($dead_regex, $it))))
return PersonStatus::Dead;
elseif (in_array("Category:Possibly living people", $category_titles))
return PersonStatus::PossiblyAlive;
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.
*
* @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
*/
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_column($output->results, "title"),
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;
/**
* @var string[] list of missing pages
* @var string[] list of missing articles
*/
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, 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)
{

View File

@ -12,11 +12,11 @@ use PDO;
class TrackingManager
{
/**
* The minimum length of a Wikipedia page title.
* The minimum length of a Wikipedia article title.
*/
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;
@ -96,21 +96,21 @@ class TrackingManager
public function add_tracking(string $user_uuid, string $person_name): Response
{
// 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
$pages_exist = $this->mediawiki->pages_exist([$person_name]);
$normalized_name = $pages_exist->redirects[$person_name];
if (in_array($normalized_name, $pages_exist->missing))
return Response::unsatisfied("Page does not exist.", "person_name");
// Validate that article exists
$article_exists = $this->mediawiki->articles_exist([$person_name]);
$normalized_name = $article_exists->redirects[$person_name];
if (in_array($normalized_name, $article_exists->missing))
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]))
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];
// 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->bindValue(":name", $normalized_name);
$stmt->execute();
@ -166,6 +166,26 @@ class TrackingManager
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.
@ -182,14 +202,14 @@ class TrackingManager
/**
* 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
*/
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) {
$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
* @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
*/
private function process_deletions(array $deletions): void
{
$stmt = $this->conn->prepare("UPDATE people SET is_deleted=1 WHERE name=:name;");
$stmt->bindParam(":name", $deleted_name);
foreach ($deletions as $deleted_name)
// TODO: Inform user that page has been deleted
$stmt->execute();
// 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) {
$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
{
// Query to mark person as no longer deleted
$undelete = $this->conn->prepare("UPDATE people SET is_deleted=0 WHERE name=:name;");
// 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);
// 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(":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) {
if ($person_status_enum === null) continue;
$person_status = $person_status_enum->value;
$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();
$status_changed = sizeof($set_status->fetchAll(PDO::FETCH_ASSOC)) > 0;
if ($status_changed) {
$get_recipients->execute();
$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));
}
if ($status_changed)
foreach ($this->get_recipients($person_name) as $user_email)
$this->mailer->queue_email(new NotifyStatusChangedEmail($user_email, $person_name, $person_status));
}
}
}
@ -314,5 +339,5 @@ enum PersonStatus: string
case Alive = "alive";
case PossiblyAlive = "possibly alive";
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
{
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;
}
}