343 lines
13 KiB
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;
|
|
}
|
|
}
|