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 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 $person_names the persons to receive subscribed email addresses for * @return array 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 $statuses the current statuses of people * @return array 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; } }