Skip to content

Commit

Permalink
Discussion Forum Notifications (#2591)
Browse files Browse the repository at this point in the history
* Basic Notification Schema

* Notification Page

* Notification Badge on Navigation Page

* Using both source and target user_id in Notification table

* Mark all as seen

* Added href on Notification page

* Notification generation, ignore source user

* Notification on New/Update to Announcement

* Notification autoseen on clicking

* Added migrations + enums

* Notification on Thread Merging

* Minor patch for e2e

* Notifications on reply

* Notification Badge on Notification button

* Removed NOT NULL for from_user_id

* Notification as model

* Notification button as bell icon

* Notification on edit/delete/undelete post or threads

* Using Notification Model for DB

* Splited switch in handleForum into functions

* Test: scroll down in threads list

* Changed messages

* E2E: Handle infinite scroll and tests

* Auto redirect to post

* Made listNotifications inline

* Renamed type to component

* Replaced mb_strimwidth with custom function
  • Loading branch information
scopeInfinity authored and bmcutler committed Aug 6, 2018
1 parent 7d6c137 commit 32bdf0e
Show file tree
Hide file tree
Showing 14 changed files with 640 additions and 29 deletions.
35 changes: 35 additions & 0 deletions migration/data/course_tables.sql
Expand Up @@ -499,6 +499,25 @@ CREATE TABLE regrade_discussion (
deleted BOOLEAN DEFAULT FALSE NOT NULL
);

--
-- Name: notifications_component_enum; Type: ENUM; Schema: public; Owner: -
--
CREATE TYPE notifications_component AS ENUM ('forum');

--
-- Name: notifications; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE notifications (
id serial NOT NULL PRIMARY KEY,
component notifications_component NOT NULL,
metadata TEXT NOT NULL,
content TEXT NOT NULL,
from_user_id VARCHAR(255),
to_user_id VARCHAR(255) NOT NULL,
created_at timestamp with time zone NOT NULL,
seen_at timestamp with time zone
);


-- Begins Forum

Expand Down Expand Up @@ -1038,12 +1057,28 @@ ALTER TABLE ONLY teams

ALTER TABLE ONLY teams
ADD CONSTRAINT teams_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(user_id) ON UPDATE CASCADE;

--
-- Name: regrade_discussion; Type: DEFAULT; Schema: public; Owner: -
--

ALTER TABLE ONLY regrade_discussion
ADD CONSTRAINT regrade_discussion_regrade_requests_id_fk FOREIGN KEY (regrade_id) REFERENCES regrade_requests(id) ON UPDATE CASCADE;

--
-- Name: notifications_to_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--

ALTER TABLE ONLY notifications
ADD CONSTRAINT notifications_to_user_id_fkey FOREIGN KEY (to_user_id) REFERENCES users(user_id) ON UPDATE CASCADE;

--
-- Name: notifications_from_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--

ALTER TABLE ONLY notifications
ADD CONSTRAINT notifications_from_user_id_fkey FOREIGN KEY (from_user_id) REFERENCES users(user_id) ON UPDATE CASCADE;

-- Forum Key relationships

ALTER TABLE "posts" ADD CONSTRAINT "posts_fk0" FOREIGN KEY ("thread_id") REFERENCES "threads"("id");
Expand Down
25 changes: 25 additions & 0 deletions migration/migrations/course/20180728172250_notifications.py
@@ -0,0 +1,25 @@
def up(config, conn, semester, course):
with conn.cursor() as cursor:
cursor.execute("DO $$\
BEGIN\
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notifications_component') THEN\
CREATE TYPE notifications_component AS ENUM ('forum');\
END IF;\
END$$;")
cursor.execute("CREATE TABLE IF NOT EXISTS notifications (\
id serial NOT NULL PRIMARY KEY,\
component notifications_component NOT NULL,\
metadata TEXT NOT NULL,\
content TEXT NOT NULL,\
from_user_id VARCHAR(255),\
to_user_id VARCHAR(255) NOT NULL,\
created_at timestamp with time zone NOT NULL,\
seen_at timestamp with time zone\
)")
cursor.execute("ALTER TABLE ONLY notifications DROP CONSTRAINT IF EXISTS notifications_to_user_id_fkey")
cursor.execute("ALTER TABLE ONLY notifications ADD CONSTRAINT notifications_to_user_id_fkey FOREIGN KEY (to_user_id) REFERENCES users(user_id) ON UPDATE CASCADE")
cursor.execute("ALTER TABLE ONLY notifications DROP CONSTRAINT IF EXISTS notifications_from_user_id_fkey")
cursor.execute("ALTER TABLE ONLY notifications ADD CONSTRAINT notifications_from_user_id_fkey FOREIGN KEY (from_user_id) REFERENCES users(user_id) ON UPDATE CASCADE")

def down(config, conn, semester, course):
pass
35 changes: 35 additions & 0 deletions site/app/controllers/NavigationController.php
Expand Up @@ -8,6 +8,7 @@
use app\models\gradeable\GradedGradeable;
use app\models\gradeable\Submitter;
use app\models\gradeable\GradeableList;
use app\models\Notification;

class NavigationController extends AbstractController {
public function __construct(Core $core) {
Expand All @@ -19,6 +20,9 @@ public function run() {
case 'no_access':
$this->noAccess();
break;
case 'notifications':
$this->notificationsHandler();
break;
default:
$this->navigationPage();
break;
Expand Down Expand Up @@ -125,4 +129,35 @@ private function filterCanView(Gradeable $gradeable) {

return true;
}


private function notificationsHandler() {
$user_id = $this->core->getUser()->getId();
if(!empty($_GET['action']) && !empty($_GET['nid']) && isset($_GET['nid'])) {
if($_GET['action'] == 'open_notification' && is_numeric($_GET['nid']) && $_GET['nid'] >= 1) {
if(!$_GET['seen']) {
$this->core->getQueries()->markNotificationAsSeen($user_id, $_GET['nid']);
}
$metadata = $this->core->getQueries()->getNotificationInfoById($user_id, $_GET['nid'])['metadata'];
$this->core->redirect(Notification::getUrl($this->core, $metadata));
} else if($_GET['action'] == 'mark_as_seen' && is_numeric($_GET['nid']) && $_GET['nid'] >= 1) {
$this->core->getQueries()->markNotificationAsSeen($user_id, $_GET['nid']);
$this->core->redirect($this->core->buildUrl(array('component' => 'navigation', 'page' => 'notifications')));
} else if($_GET['action'] == 'mark_all_as_seen') {
$this->core->getQueries()->markNotificationAsSeen($user_id, -1);
$this->core->redirect($this->core->buildUrl(array('component' => 'navigation', 'page' => 'notifications')));
}
} else {
// Show Notifications
$show_all = (!empty($_GET['show_all']) && $_GET['show_all'])?true:false;
$notifications = $this->core->getQueries()->getUserNotifications($user_id, $show_all);
$currentCourse = $this->core->getConfig()->getCourse();
$this->core->getOutput()->addBreadcrumb("Notifications", $this->core->buildUrl(array('component' => 'navigation', 'page' => 'notifications')));
return $this->core->getOutput()->renderTwigOutput("Notifications.twig", [
'course' => $currentCourse,
'show_all' => $show_all,
'notifications' => $notifications
]);
}
}
}
43 changes: 41 additions & 2 deletions site/app/controllers/forum/ForumController.php
Expand Up @@ -3,6 +3,7 @@
namespace app\controllers\forum;

use app\libraries\Core;
use app\models\Notification;
use app\controllers\AbstractController;
use app\libraries\Output;
use app\libraries\Utils;
Expand Down Expand Up @@ -333,6 +334,10 @@ public function publishThread(){
}

}
if($announcment){
$notification = new Notification($this->core, array('component' => 'forum', 'type' => 'new_announcement', 'thread_id' => $id, 'thread_title' => $title));
$this->core->getQueries()->pushNotification($notification);
}
$result['next_page'] = $this->core->buildUrl(array('component' => 'forum', 'page' => 'view_thread', 'thread_id' => $id));
}
}
Expand Down Expand Up @@ -383,6 +388,11 @@ public function publishPost(){
move_uploaded_file($_FILES[$file_post]["tmp_name"][$i], $target_file);
}
}
// Notification to parent post author
$post = $this->core->getQueries()->getPost($parent_id);
$post_author = $post['author_user_id'];
$notification = new Notification($this->core, array('component' => 'forum', 'type' => 'reply', 'thread_id' => $thread_id, 'post_id' => $parent_id, 'post_content' => $post['content'], 'reply_to' => $post_author));
$this->core->getQueries()->pushNotification($notification);
$result['next_page'] = $this->core->buildUrl(array('component' => 'forum', 'page' => 'view_thread', 'option' => $display_option, 'thread_id' => $thread_id));
}
}
Expand All @@ -394,6 +404,10 @@ public function alterAnnouncement($type){
if($this->core->getUser()->getGroup() <= 2){
$thread_id = $_POST["thread_id"];
$this->core->getQueries()->setAnnouncement($thread_id, $type);
if($type) {
$notification = new Notification($this->core, array('component' => 'forum', 'type' => 'updated_announcement', 'thread_id' => $thread_id, 'thread_title' => $this->core->getQueries()->getThreadTitle($thread_id)['title']));
$this->core->getQueries()->pushNotification($notification);
}
} else {
$this->core->addErrorMessage("You do not have permissions to do that.");
}
Expand Down Expand Up @@ -458,6 +472,10 @@ public function alterPost($modifyType){
} else {
$type = "post";
}
$post = $this->core->getQueries()->getPost($post_id);
$post_author = $post['author_user_id'];
$notification = new Notification($this->core, array('component' => 'forum', 'type' => 'deleted', 'thread_id' => $thread_id, 'post_content' => $post['content'], 'reply_to' => $post_author));
$this->core->getQueries()->pushNotification($notification);
$this->core->getOutput()->renderJson($response = array('type' => $type));
return $response;
} else if($modifyType == 2) { //undelete post or thread
Expand All @@ -476,6 +494,10 @@ public function alterPost($modifyType){
} else {
/// We want to reload same thread again, in both case (thread/post undelete)
$type = "post";
$post = $this->core->getQueries()->getPost($post_id);
$post_author = $post['author_user_id'];
$notification = new Notification($this->core, array('component' => 'forum', 'type' => 'undeleted', 'thread_id' => $thread_id, 'post_id' => $post_id, 'post_content' => $post['content'], 'reply_to' => $post_author));
$this->core->getQueries()->pushNotification($notification);
$this->core->getOutput()->renderJson($response = array('type' => $type));
}
return $response;
Expand All @@ -488,6 +510,7 @@ public function alterPost($modifyType){
}
$status_edit_thread = $this->editThread();
$status_edit_post = $this->editPost();
$any_changes = false;
// Author of first post and thread must be same
if(is_null($status_edit_thread) && is_null($status_edit_post)) {
$this->core->addErrorMessage("No data submitted. Please try again.");
Expand All @@ -496,23 +519,32 @@ public function alterPost($modifyType){
if($status_edit_thread || $status_edit_post) {
//$type is true
$this->core->addSuccessMessage("{$type} updated successfully.");
$any_changes = true;
} else {
$this->core->addErrorMessage("{$type} updation failed. Please try again.");
}
} else {
if($status_edit_thread && $status_edit_post) {
$this->core->addSuccessMessage("Thread and post updated successfully.");
$any_changes = true;
} else {
$type = ($status_edit_thread)?"Thread":"Post";
$type_opposite = (!$status_edit_thread)?"Thread":"Post";
if($status_edit_thread || $status_edit_post) {
//$type is true
$this->core->addErrorMessage("{$type} updated successfully. {$type_opposite} updation failed. Please try again.");
$any_changes = true;
} else {
$this->core->addErrorMessage("Thread and Post updation failed. Please try again.");
}
}
}
if($any_changes) {
$post = $this->core->getQueries()->getPost($post_id);
$post_author = $post['author_user_id'];
$notification = new Notification($this->core, array('component' => 'forum', 'type' => 'edited', 'thread_id' => $thread_id, 'post_id' => $post_id, 'post_content' => $post['content'], 'reply_to' => $post_author));
$this->core->getQueries()->pushNotification($notification);
}
$this->core->redirect($this->core->buildUrl(array('component' => 'forum', 'page' => 'view_thread', 'thread_id' => $thread_id)));
}
}
Expand Down Expand Up @@ -803,7 +835,8 @@ public function mergeThread(){
if($this->core->getUser()->getGroup() <= 2){
if(is_numeric($parent_thread_id) && is_numeric($child_thread_id)) {
$message = "";
if($this->core->getQueries()->mergeThread($parent_thread_id, $child_thread_id, $message)) {
$child_root_post = -1;
if($this->core->getQueries()->mergeThread($parent_thread_id, $child_thread_id, $message, $child_root_post)) {
$child_thread_dir = FileUtils::joinPaths(FileUtils::joinPaths($this->core->getConfig()->getCoursePath(), "forum_attachments"), $child_thread_id);
if(is_dir($child_thread_dir)) {
$parent_thread_dir = FileUtils::joinPaths(FileUtils::joinPaths($this->core->getConfig()->getCoursePath(), "forum_attachments"), $parent_thread_id);
Expand All @@ -817,6 +850,13 @@ public function mergeThread(){
rename($child_post_dir, $parent_post_dir);
}
}
// Notify thread author
$child_thread = $this->core->getQueries()->getThread($child_thread_id)[0];
$child_thread_author = $child_thread['created_by'];
$child_thread_title = $child_thread['title'];
$parent_thread_title =$this->core->getQueries()->getThreadTitle($parent_thread_id)['title'];
$notification = new Notification($this->core, array('component' => 'forum', 'type' => 'merge_thread', 'child_thread_id' => $child_thread_id, 'parent_thread_id' => $parent_thread_id, 'child_thread_title' => $child_thread_title, 'parent_thread_title' => $parent_thread_title, 'child_thread_author' => $child_thread_author, 'child_root_post' => $child_root_post));
$this->core->getQueries()->pushNotification($notification);
$this->core->addSuccessMessage("Threads merged!");
$thread_id = $parent_thread_id;
} else {
Expand All @@ -828,5 +868,4 @@ public function mergeThread(){
}
$this->core->redirect($this->core->buildUrl(array('component' => 'forum', 'page' => 'view_thread', 'thread_id' => $thread_id)));
}

}
106 changes: 105 additions & 1 deletion site/app/libraries/database/DatabaseQueries.php
Expand Up @@ -21,6 +21,7 @@
use app\models\GradeableComponentMark;
use app\models\GradeableVersion;
use app\models\User;
use app\models\Notification;
use app\models\SimpleLateUser;
use app\models\Team;
use app\models\Course;
Expand Down Expand Up @@ -2313,7 +2314,7 @@ public function getRootPostOfNonMergedThread($thread_id, &$title, &$message) {
return $root_post;
}

public function mergeThread($parent_thread_id, $child_thread_id, &$message){
public function mergeThread($parent_thread_id, $child_thread_id, &$message, &$child_root_post){
try{
$this->course_db->beginTransaction();
$parent_thread_title = null;
Expand Down Expand Up @@ -2380,6 +2381,109 @@ public function getAllAnonIds() {
return $this->course_db->rows();
}

/**
* Generate notifcation rows
*
* @param Notification $notification
*/
public function pushNotification($notification){
$params = array();
$params[] = $notification->getComponent();
$params[] = $notification->getNotifyMetadata();
$params[] = $notification->getNotifyContent();
$params[] = $notification->getNotifySource();

if(empty($notification->getNotifyTarget())) {
// Notify all users
$target_users_query = "SELECT user_id FROM users";
} else {
// To a specific user
$params[] = $notification->getNotifyTarget();
$target_users_query = "SELECT ?::text as user_id";
}

if($notification->getNotifyNotToSource()){
$ignore_self_query = "WHERE user_id <> ?";
$params[] = $notification->getNotifySource();
}
else {
$ignore_self_query = "";
}
$this->course_db->query("INSERT INTO notifications(component, metadata, content, created_at, from_user_id, to_user_id)
SELECT ?, ?, ?, current_timestamp, ?, user_id as to_user_id FROM ({$target_users_query}) as u {$ignore_self_query}",
$params);
}

/**
* Returns notifications for a user
*
* @param string $user_id
* @param bool $show_all
* @return array(Notification)
*/
public function getUserNotifications($user_id, $show_all){
if($show_all){
$seen_status_query = "true";
} else {
$seen_status_query = "seen_at is NULL";
}
$this->course_db->query("SELECT id, component, metadata, content,
(case when seen_at is NULL then false else true end) as seen,
(extract(epoch from current_timestamp) - extract(epoch from created_at)) as elapsed_time, created_at
FROM notifications WHERE to_user_id = ? and {$seen_status_query} ORDER BY created_at DESC", array($user_id));
$rows = $this->course_db->rows();
$results = array();
foreach ($rows as $row) {
$results[] = new Notification($this->core, array(
'view_only' => true,
'id' => $row['id'],
'component' => $row['component'],
'metadata' => $row['metadata'],
'content' => $row['content'],
'seen' => $row['seen'],
'elapsed_time' => $row['elapsed_time'],
'created_at' => $row['created_at']
));
}
return $results;
}

public function getNotificationInfoById($user_id, $notification_id){
$this->course_db->query("SELECT metadata FROM notifications WHERE to_user_id = ? and id = ?", array($user_id, $notification_id));
return $this->course_db->row();
}

public function getUnreadNotificationsCount($user_id, $component){
$parameters = array($user_id);
if(is_null($component)){
$component_query = "true";
} else {
$component_query = "component = ?";
$parameters[] = $component;
}
$this->course_db->query("SELECT count(*) FROM notifications WHERE to_user_id = ? and seen_at is NULL and {$component_query}", $parameters);
return $this->course_db->row()['count'];
}

/**
* Marks $user_id notifications as seen
*
* @param sting $user_id
* @param int $notification_id if $notification_id != -1 then marks corresponding as seen else mark all notifications as seen
*/
public function markNotificationAsSeen($user_id, $notification_id){
$parameters = array();
$parameters[] = $user_id;
if($notification_id == -1) {
$id_query = "true";
} else {
$id_query = "id = ?";
$parameters[] = $notification_id;
}
$this->course_db->query("UPDATE notifications SET seen_at = current_timestamp
WHERE to_user_id = ? and seen_at is NULL and {$id_query}", $parameters);
}

/**
* Determines if a course is 'active' or if it was dropped.
*
Expand Down

0 comments on commit 32bdf0e

Please sign in to comment.