death-notifier/src/main/php/com/fwdekker/deathnotifier/tracking/TrackingList.php

343 lines
13 KiB
PHP

<?php
namespace com\fwdekker\deathnotifier\tracking;
use com\fwdekker\deathnotifier\Database;
use com\fwdekker\deathnotifier\wikipedia\PersonStatus;
use com\fwdekker\deathnotifier\wikipedia\Redirects;
use PDO;
/**
* A list of trackings, stored in a {@see Database}.
*
* A tracking is a `(user, person)` pair, where a `user` is an end-user and a `person` is a real-world person that has
* a Wikipedia page. A tracking signifies the fact that the `user` wishes to track a `person`.
*/
class TrackingList
{
/**
* The minimum length of a Wikipedia article title.
*/
public const MIN_TITLE_LENGTH = 1;
/**
* The maximum length of a Wikipedia article title.
*/
public const MAX_TITLE_LENGTH = 255;
/**
* @var Database the database to store trackings in
*/
private readonly Database $database;
/**
* Constructs a new `TrackingList`.
*
* @param Database $database the database to store trackings in
*/
public function __construct(Database $database)
{
$this->database = $database;
}
/**
* Populates the {@see Database} with the necessary structures for a `TrackingList`.
*
* @return void
*/
public function install(): void
{
$conn = $this->database->conn;
$conn->exec("CREATE TABLE trackings(user_uuid TEXT NOT NULL,
person_name TEXT NOT NULL,
since INT NOT NULL DEFAULT(unixepoch()),
PRIMARY KEY (user_uuid, person_name),
FOREIGN KEY (user_uuid) REFERENCES users (uuid)
ON DELETE CASCADE
ON UPDATE CASCADE,
FOREIGN KEY (person_name) REFERENCES people (name)
ON DELETE CASCADE
ON UPDATE CASCADE);");
$conn->exec("CREATE TABLE people(name TEXT NOT NULL UNIQUE PRIMARY KEY,
status TEXT NOT NULL DEFAULT(''),
is_deleted INT NOT NULL DEFAULT(0));");
$conn->exec("CREATE TRIGGER people_cull_orphans
AFTER DELETE ON trackings
FOR EACH ROW
WHEN (SELECT COUNT(*) FROM trackings WHERE person_name=OLD.person_name)=0
BEGIN
DELETE FROM people WHERE name=OLD.person_name;
END;");
}
/**
* Executes {@see $lambda} within a single database transaction.
*
* @param callable(): void $lambda the function to execute within a transaction
* @return void
* @see Database::transaction()
*/
public function transaction(callable $lambda): void
{
$this->database->transaction($lambda);
}
/**
* Adds a tracking to the database.
*
* @param string $user_uuid the UUID of the user for whom to add the tracking
* @param string $person_name the name of the person to track
* @param PersonStatus $status the status of the person to track
* @return void
*/
public function add_tracking(string $user_uuid, string $person_name, PersonStatus $status): void
{
$this->transaction(function () use ($user_uuid, $person_name, $status) {
$conn = $this->database->conn;
$stmt = $conn->prepare("INSERT OR IGNORE INTO people (name) VALUES (:name);");
$stmt->bindValue(":name", $person_name);
$stmt->execute();
$stmt = $conn->prepare("UPDATE people SET status=:status WHERE name=:name;");
$stmt->bindValue(":name", $person_name);
$stmt->bindValue(":status", $status->value);
$stmt->execute();
$stmt = $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", $person_name);
$stmt->execute();
});
}
/**
* Returns `true` if and only if the given user currently tracks the given person.
*
* @param string $user_uuid the UUID of the user to check
* @param string $person_name the person to check
* @return bool `true` if and only if the given user currently tracks the given person
*/
public function has_tracking(string $user_uuid, string $person_name): bool
{
$stmt = $this->database->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 void
*/
public function remove_tracking(string $user_uuid, string $person_name): void
{
$stmt = $this->database->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();
}
/**
* Returns all trackings of the given user.
*
* @param string $user_uuid the user to return the trackings of
* @return array<array{"name": string, "status": string, "is_deleted": bool}> all trackings of the given user
*/
public function list_trackings(string $user_uuid): array
{
$stmt = $this->database->conn->prepare("SELECT people.name, people.status, people.is_deleted, since
FROM trackings
INNER JOIN people
ON trackings.user_uuid=:user_uuid
AND trackings.person_name=people.name;");
$stmt->bindValue(":user_uuid", $user_uuid);
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Returns the email addresses of all users who should be notified of events relating to the given person.
*
* @param array<string> $person_names the persons to receive subscribed email addresses for
* @return array<string, string[]> a map of each {@see $person_names} to the email addresses of all users tracking
* that person
*/
public function list_trackers(array $person_names): array
{
$stmt = $this->database->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);
$trackers = [];
foreach ($person_names as $person_name) {
$stmt->execute();
$trackers[$person_name] = array_column($stmt->fetchAll(PDO::FETCH_ASSOC), "email");
}
return $trackers;
}
/**
* Lists all unique names being tracked in the database.
*
* @return string[] all unique names in the database
*/
public function list_all_unique_person_names(): array
{
$stmt = $this->database->conn->prepare("SELECT ALL name FROM people;");
$stmt->execute();
return array_column($stmt->fetchAll(PDO::FETCH_ASSOC), "name");
}
/**
* Renames people in the database.
*
* @param Redirects $renamings a map of all changes, from old name to new name
* @return void
*/
public function rename_persons(Redirects $renamings): void
{
$this->transaction(function () use ($renamings) {
$conn = $this->database->conn;
// Query to rename person
$rename = $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 = $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 = $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 = $conn->prepare("DELETE FROM people WHERE name=:old_name;");
$merge_remove->bindParam(":old_name", $from);
// 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();
}
}
});
}
/**
* Marks people as deleted in the database.
*
* @param string[] $deletions list of names of people to mark as deleted in the database
* @return string[] subset of {@see $deletions} containing the names of people that were newly marked as deleted
*/
public function delete_persons(array $deletions): array
{
$new_deletions = [];
$this->transaction(function () use ($deletions, &$new_deletions) {
$stmt = $this->database->conn->prepare("UPDATE people
SET is_deleted=1
WHERE name=:name AND is_deleted<>1
RETURNING name;");
$stmt->bindParam(":name", $deleted_name);
foreach ($deletions as $deleted_name) {
$stmt->execute();
if (sizeof($stmt->fetchAll(PDO::FETCH_ASSOC)) > 0)
$new_deletions[] = $deleted_name;
}
});
return $new_deletions;
}
/**
* Marks people as undeleted in the database.
*
* @param string[] $undeletions list of names of people to mark as undeleted in the database
* @return string[] subset of {@see $undeletions} containing the names of people that were newly marked as undeleted
*/
public function undelete_persons(array $undeletions): array
{
$new_undeletions = [];
$this->transaction(function () use ($undeletions, &$new_undeletions) {
$stmt = $this->database->conn->prepare("UPDATE people
SET is_deleted=0
WHERE name=:name AND is_deleted<>0
RETURNING name;");
$stmt->bindParam(":name", $undeleted_name);
foreach ($undeletions as $undeleted_name) {
$stmt->execute();
if (sizeof($stmt->fetchAll(PDO::FETCH_ASSOC)) > 0)
$new_undeletions[] = $undeleted_name;
}
});
return $new_undeletions;
}
/**
* Updates peoples' statuses.
*
* @param array<string, PersonStatus|null> $statuses the current statuses of people
* @return array<string, PersonStatus> a mapping of articles that were actually changes to the new status
*/
public function update_statuses(array $statuses): array
{
$status_changes = [];
$this->transaction(function () use ($statuses, &$status_changes) {
$stmt = $this->database->conn->prepare("UPDATE people
SET status=:status
WHERE name=:name AND status<>:status
RETURNING name;");
$stmt->bindParam(":status", $person_status_string);
$stmt->bindParam(":name", $person_name);
foreach ($statuses as $person_name => $person_status) {
if ($person_status === null) continue;
$person_status_string = $person_status->value;
$stmt->execute();
if (sizeof($stmt->fetchAll(PDO::FETCH_ASSOC)) > 0)
$status_changes[$person_name] = $person_status;
}
});
return $status_changes;
}
}