From e160ba5fbe83c3a52f9b2f3619b84f4fc3daa9a9 Mon Sep 17 00:00:00 2001
From: Jan-Hendrik Willms <tleilax+studip@gmail.com>
Date: Wed, 7 Dec 2022 10:56:30 +0000
Subject: [PATCH] migrate from storing last visit dates of blubber threads in
 config_values to...

Closes #744

Merge request studip/studip!1219
---
 app/routes/Blubber.php                        | 19 ++----
 ...bber_user_config_to_object_user_visits.php | 66 +++++++++++++++++++
 lib/classes/sidebar/BlubberThreadsWidget.php  | 22 ++++---
 lib/models/BlubberThread.php                  | 55 +++++++++++++---
 lib/models/ObjectUserVisit.php                | 28 ++++++++
 lib/modules/Blubber.class.php                 | 12 +++-
 6 files changed, 172 insertions(+), 30 deletions(-)
 create mode 100644 db/migrations/5.1.37_migrate_blubber_user_config_to_object_user_visits.php
 create mode 100644 lib/models/ObjectUserVisit.php

diff --git a/app/routes/Blubber.php b/app/routes/Blubber.php
index a30feb9a1ea..4fb6ab9d87e 100644
--- a/app/routes/Blubber.php
+++ b/app/routes/Blubber.php
@@ -18,7 +18,7 @@ class Blubber extends \RESTAPI\RouteMap
      *
      * @get /blubber/threads/:thread_id
      * @param string $thread_id   id of the blubber thread or "global" if you want public threads (not comments). Remind the global thread is a virtual thread with a special behaviour.
-     * @return Array   the blubber as array
+     * @return array   the blubber as array
      */
     public function getThreadData($thread_id)
     {
@@ -31,7 +31,6 @@ class Blubber extends \RESTAPI\RouteMap
         $thread = \BlubberThread::upgradeThread($thread);
         if (!$thread->isReadable()) {
             $this->error(401);
-            return;
         }
 
         $json = $thread->getJSONData(50, null, \Request::get("search"));
@@ -46,7 +45,7 @@ class Blubber extends \RESTAPI\RouteMap
      * Get threads
      *
      * @get /blubber/threads
-     * @return Array   the stream as array
+     * @return array   the stream as array
      */
     public function getMyThreads()
     {
@@ -83,7 +82,7 @@ class Blubber extends \RESTAPI\RouteMap
      *
      * @post /blubber/threads/:thread_id/comments
      * @param string $thread_id   id of the blubber thread
-     * @return Array   the comment as array
+     * @return array   the comment as array
      */
     public function postComment($thread_id)
     {
@@ -93,13 +92,11 @@ class Blubber extends \RESTAPI\RouteMap
 
         if (!trim($this->data['content'])) {
             $this->error(406);
-            return false;
         }
 
         $thread = \BlubberThread::find($thread_id);
         if (!$thread->isCommentable()) {
             $this->error(401);
-            return;
         }
 
         $comment = new \BlubberComment();
@@ -109,7 +106,7 @@ class Blubber extends \RESTAPI\RouteMap
         $comment['external_contact'] = 0;
         $comment->store();
 
-        $GLOBALS['user']->cfg->store("BLUBBERTHREAD_VISITED_".$thread_id, time());
+        $thread->setLastVisit();
 
         return $comment->getJSONData();
     }
@@ -122,14 +119,13 @@ class Blubber extends \RESTAPI\RouteMap
      * @param string $thread_id   id of the blubber thread
      * @param string $comment     id of the comment
      *
-     * @return Array   the comment as array
+     * @return array   the comment as array
      */
     public function editComment($thread_id, $comment_id)
     {
         $comment = \BlubberComment::find($comment_id);
         if (!$comment->isWritable()) {
             $this->error(401);
-            return;
         }
         $old_content = $comment['content'];
         $comment['content'] = $this->data['content'];
@@ -176,7 +172,7 @@ class Blubber extends \RESTAPI\RouteMap
      *
      * @param string $thread_id   id of the blubber thread
      *
-     * @return Array   the comments as array
+     * @return array   the comments as array
      */
     public function getComments($thread_id)
     {
@@ -187,7 +183,6 @@ class Blubber extends \RESTAPI\RouteMap
         $thread = new \BlubberThread($thread_id);
         if (!$thread->isReadable()) {
             $this->error(401);
-            return;
         }
 
         $modifier = \Request::get('modifier');
@@ -228,7 +223,7 @@ class Blubber extends \RESTAPI\RouteMap
                       FROM blubber_comments
                       WHERE blubber_comments.thread_id = :thread_id
                         AND blubber_comments.mkdate >= :timestamp
-                      ORDER BY mkdate ASC
+                      ORDER BY mkdate
                       LIMIT :limit";
             $comments = \DBManager::get()->fetchAll($query, [
                 'thread_id' => $thread_id,
diff --git a/db/migrations/5.1.37_migrate_blubber_user_config_to_object_user_visits.php b/db/migrations/5.1.37_migrate_blubber_user_config_to_object_user_visits.php
new file mode 100644
index 00000000000..69e7ea64b7a
--- /dev/null
+++ b/db/migrations/5.1.37_migrate_blubber_user_config_to_object_user_visits.php
@@ -0,0 +1,66 @@
+<?php
+final class MigrateBlubberUserConfigToObjectUserVisits extends Migration
+{
+    public function description()
+    {
+        return 'Migrates the blubber visited entrires from config_values to user_entries';
+    }
+
+    protected function up()
+    {
+        $query = "SELECT `pluginid`
+                  FROM `plugins`
+                  WHERE `pluginclassname` = 'Blubber'";
+        $blubber_plugin_id = DBManager::get()->fetchColumn($query);
+
+        $query = "INSERT INTO `object_user_visits` (
+                     `object_id`,
+                     `user_id`,
+                     `plugin_id`,
+                     `visitdate`,
+                     `last_visitdate`
+                  )
+                  SELECT SUBSTR(`field`, 23) AS `object_id`,
+                     `range_id` AS `user_id`,
+                     ? AS `plugin_id`,
+                     `value` AS `visitdate`,
+                     `value` AS `last_visitdate`
+                  FROM `config_values`
+                  WHERE `field` LIKE 'BLUBBERTHREAD\\_VISITED\\_%'";
+        DBManager::get()->execute($query, [$blubber_plugin_id]);
+
+        $query = "DELETE FROM `config_values`
+                  WHERE `field` LIKE 'BLUBBERTHREAD\\_VISITED\\_%'";
+        DBManager::get()->exec($query);
+    }
+
+    protected function down()
+    {
+        $query = "SELECT `pluginid`
+                  FROM `plugins`
+                  WHERE `pluginclassname` = 'Blubber'";
+        $blubber_plugin_id = DBManager::get()->fetchColumn($query);
+
+        $query = "INSERT INTO `config_values` (
+                     `field`,
+                     `range_id`,
+                     `value`,
+                     `mkdate`,
+                     `chdate`,
+                     `comment`
+                  )
+                  SELECT CONCAT('BLUBBERTHREAD_VISITED_', `object_id`) AS `field`,
+                     `user_id` AS `range_id`,
+                     `visitdate` AS `value`,
+                     UNIX_TIMESTAMP() AS `mkdate`,
+                     UNIX_TIMESTAMP() AS `chdate`,
+                     '' AS `comment`
+                  FROM `object_user_visits`
+                  WHERE `plugin_id` = ?";
+        DBManager::get()->execute($query, [$blubber_plugin_id]);
+
+        $query = "DELETE FROM `object_user_visits`
+                  WHERE `plugin_id` = ?";
+        DBManager::get()->execute($query, [$blubber_plugin_id]);
+    }
+}
diff --git a/lib/classes/sidebar/BlubberThreadsWidget.php b/lib/classes/sidebar/BlubberThreadsWidget.php
index b02e027e2d1..db80023fa45 100644
--- a/lib/classes/sidebar/BlubberThreadsWidget.php
+++ b/lib/classes/sidebar/BlubberThreadsWidget.php
@@ -1,10 +1,16 @@
 <?php
 
+/**
+ * @property BlubberThread[] $elements
+ */
 class BlubberThreadsWidget extends SidebarWidget
 {
     protected $active_thread = null;
     protected $with_composer = false;
 
+    /**
+     * @param BlubberThread $thread
+     */
     public function addThread($thread)
     {
         $this->elements[] = $thread;
@@ -32,18 +38,18 @@ class BlubberThreadsWidget extends SidebarWidget
         foreach ($this->elements as $thread) {
             $unseen_comments = BlubberComment::countBySQL("thread_id = ? AND mkdate >= ?", [
                 $thread->getId(),
-                $thread->getLastVisit() ?: object_get_visit_threshold()
+                $thread->getLastVisit()
             ]);
 
             $json[] = [
-                'thread_id' => $thread->getId(),
-                'avatar' => $thread->getAvatar(),
-                'name' => $thread->getName(),
-                'timestamp' => (int) $thread->getLatestActivity(),
-                'mkdate' => (int) $thread->mkdate,
+                'thread_id'       => $thread->getId(),
+                'avatar'          => $thread->getAvatar(),
+                'name'            => $thread->getName(),
+                'timestamp'       => (int) $thread->getLatestActivity(),
+                'mkdate'          => (int) $thread->mkdate,
                 'unseen_comments' => $unseen_comments,
-                'notifications' => $thread->id === 'global' || ($thread->context_type === 'course' && !$GLOBALS['perm']->have_perm('admin')),
-                'followed' => $thread->isFollowedByUser(),
+                'notifications'   => $thread->id === 'global' || ($thread->context_type === 'course' && !$GLOBALS['perm']->have_perm('admin')),
+                'followed'        => $thread->isFollowedByUser(),
             ];
         }
 
diff --git a/lib/models/BlubberThread.php b/lib/models/BlubberThread.php
index 0048c219c68..d0f2f89ecd7 100644
--- a/lib/models/BlubberThread.php
+++ b/lib/models/BlubberThread.php
@@ -41,14 +41,17 @@ class BlubberThread extends SimpleORMap implements PrivacyObject
             'foreign_key'       => 'user_id',
             'assoc_foreign_key' => 'user_id',
         ];
+        $config['has_many']['visits'] = [
+            'class_name'        => ObjectUserVisit::class,
+            'assoc_foreign_key' => 'object_id',
+            'on_delete'         => 'delete',
+        ];
 
         $config['serialized_fields']['metadata'] = 'JSONArrayObject';
 
         parent::configure($config);
     }
 
-    protected $last_visit = null;
-
     /**
      * Recognizes mentions in blubber as @username or @"Firstname lastname"
      * and turns them into usual studip-links. The mentioned person is notified by
@@ -95,6 +98,9 @@ class BlubberThread extends SimpleORMap implements PrivacyObject
         return $matches[0];
     }
 
+    /**
+     * @return BlubberThread[]
+     */
     public static function findBySQL($sql, $params = [])
     {
         return parent::findAndMapBySQL(function ($thread) {
@@ -102,6 +108,9 @@ class BlubberThread extends SimpleORMap implements PrivacyObject
         }, $sql, $params);
     }
 
+    /**
+     * @return BlubberThread|null
+     */
     public static function find($id)
     {
         return self::upgradeThread(parent::find($id));
@@ -598,7 +607,39 @@ class BlubberThread extends SimpleORMap implements PrivacyObject
      */
     public function getLastVisit(string $user_id = null)
     {
-        return UserConfig::get($user_id ?? $GLOBALS['user']->id)->getValue("BLUBBERTHREAD_VISITED_".$this->getId());
+        return object_get_visit(
+            $this->id,
+            $this->getBlubberPluginId(),
+            '',
+            '',
+            $user_id ?? User::findCurrent()->id
+        );
+    }
+
+    /**
+     * Sets the last visit timestamp for this thread
+     *
+     * @param string|null $user_id
+     */
+    public function setLastVisit(string $user_id = null): void
+    {
+        object_set_visit(
+            $this->id,
+            $this->getBlubberPluginId(),
+            $user_id ?? User::findCurrent()->id
+        );
+    }
+
+    /**
+     * Returns the id of the blubber plugin.
+     *
+     * @return int Id of the plugin
+     */
+    protected function getBlubberPluginId(): int
+    {
+        $plugin_info = PluginManager::getInstance()->getPluginInfo(Blubber::class);
+        return (int) $plugin_info['id'];
+
     }
 
     public function notifyUsersForNewComment($comment)
@@ -843,7 +884,7 @@ class BlubberThread extends SimpleORMap implements PrivacyObject
             'more_down'       => 0,
             'unseen_comments' => BlubberComment::countBySQL("thread_id = ? AND mkdate >= ? AND user_id != ?", [
                 $this->getId(),
-                $this->getLastVisit() ?: object_get_visit_threshold(),
+                $this->getLastVisit(),
                 $user_id
             ]),
             'notifications' => $this->mayDisableNotifications(),
@@ -919,10 +960,8 @@ class BlubberThread extends SimpleORMap implements PrivacyObject
             'user_id' => $user_id,
             'html_id' => "blubberthread_".$this->getId()
         ]);
-        $this->last_visit[$user_id] = empty($this->last_visit[$user_id])
-            ? object_get_visit($this->getId(), "blubberthread", "last", "", $user_id)
-            : $this->last_visit[$user_id];
-        UserConfig::get($user_id)->store("BLUBBERTHREAD_VISITED_".$this->getId(), time());
+
+        $this->setLastVisit($user_id);
     }
 
     public function getHashtags($since = null)
diff --git a/lib/models/ObjectUserVisit.php b/lib/models/ObjectUserVisit.php
new file mode 100644
index 00000000000..90a331e042a
--- /dev/null
+++ b/lib/models/ObjectUserVisit.php
@@ -0,0 +1,28 @@
+<?php
+
+/**
+ * @property array $id
+ * @property string $object_id
+ * @property string $user_id
+ * @property int $plugin_id
+ * @property int $visitdate
+ * @property int $last_visitdate
+ * @property User $user
+ */
+class ObjectUserVisit extends SimpleORMap
+{
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'object_user_visits';
+
+        $config['belongs_to'] = [
+            'user' => [
+                'class_name'        => User::class,
+                'foreign_key'       => 'user_id',
+                'assoc_foreign_key' => 'user_id',
+            ]
+        ];
+
+        parent::configure($config);
+    }
+}
diff --git a/lib/modules/Blubber.class.php b/lib/modules/Blubber.class.php
index d9b5579fafd..d2012b622af 100644
--- a/lib/modules/Blubber.class.php
+++ b/lib/modules/Blubber.class.php
@@ -59,7 +59,11 @@ class Blubber extends CorePlugin implements StudipModule
             'me'         => $user_id,
         ]);
         foreach ($comments as $comment) {
-            if ($comment->thread->isVisibleInStream() && $comment->thread->isReadable() && ($comment->thread->getLatestActivity() > UserConfig::get($user_id)->getValue("BLUBBERTHREAD_VISITED_".$comment['thread_id']))) {
+            if (
+                $comment->thread->isVisibleInStream()
+                && $comment->thread->isReadable()
+                && $comment->thread->getLatestActivity() > $comment->thread->getLastVisit()
+            ) {
                 $icon->setImage(Icon::create('blubber', Icon::ROLE_NEW, ['title' => _('Es gibt neue Blubber')]));
                 $icon->setTitle(_('Es gibt neue Blubber'));
                 $icon->setBadgeNumber(count($comments));
@@ -83,7 +87,11 @@ class Blubber extends CorePlugin implements StudipModule
             'me'         => $GLOBALS['user']->id,
         ]);
         foreach ($threads as $thread) {
-            if ($thread->isVisibleInStream() && $thread->isReadable() && ($thread['mkdate'] > UserConfig::get($user_id)->getValue("BLUBBERTHREAD_VISITED_".$thread->getId()))) {
+            if (
+                $thread->isVisibleInStream()
+                && $thread->isReadable()
+                && $thread->mkdate > $thread->getLastVisit()
+            ) {
                 $icon->setImage(Icon::create('blubber', Icon::ROLE_ATTENTION, ['title' => _('Es gibt neue Blubber')]));
                 $icon->setTitle(_('Es gibt neue Blubber'));
                 break;
-- 
GitLab