mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-18 01:28:58 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f237881df | ||
| d73d99c596 | |||
| 96ecf51162 |
@@ -1 +1 @@
|
||||
{".":"0.29.1"}
|
||||
{".":"0.29.2"}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## [0.29.2](https://github.com/chenasraf/nextcloud-forum/compare/v0.29.1...v0.29.2) (2026-03-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* stats rebuild task ([d73d99c](https://github.com/chenasraf/nextcloud-forum/commit/d73d99c5966017261450071bf183b6d67d517fbb))
|
||||
* thread sorting ([96ecf51](https://github.com/chenasraf/nextcloud-forum/commit/96ecf511623939641ed09e4eb48be0568b60fbb1))
|
||||
|
||||
## [0.29.1](https://github.com/chenasraf/nextcloud-forum/compare/v0.29.0...v0.29.1) (2026-03-22)
|
||||
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Create discussions, share ideas and collaborate with your community directly in
|
||||
|
||||
The forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control.
|
||||
]]></description>
|
||||
<version>0.29.1</version>
|
||||
<version>0.29.2</version>
|
||||
<licence>agpl</licence>
|
||||
<author mail="contact@casraf.dev" homepage="https://casraf.dev">Chen Asraf</author>
|
||||
<namespace>Forum</namespace>
|
||||
|
||||
@@ -409,14 +409,13 @@ class PostController extends OCSController {
|
||||
}
|
||||
}
|
||||
|
||||
// Update the thread's post count and timestamps
|
||||
// Update the thread's post count and last reply info
|
||||
try {
|
||||
$thread = $this->threadMapper->find($threadId);
|
||||
$thread->setPostCount($thread->getPostCount() + 1);
|
||||
$thread->setLastPostId($createdPost->getId());
|
||||
$thread->setLastReplyAuthorId($createdPost->getAuthorId());
|
||||
$thread->setLastReplyAt($createdPost->getCreatedAt());
|
||||
$thread->setUpdatedAt(time());
|
||||
$this->threadMapper->update($thread);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update thread post count: ' . $e->getMessage());
|
||||
@@ -568,8 +567,6 @@ class PostController extends OCSController {
|
||||
if (!$post->getIsFirstPost()) {
|
||||
$thread->setPostCount(max(0, $thread->getPostCount() - 1));
|
||||
}
|
||||
$thread->setUpdatedAt(time());
|
||||
|
||||
// If the deleted post was the last post, update lastPostId to the previous non-deleted post
|
||||
if ($thread->getLastPostId() === $post->getId()) {
|
||||
// Find the latest non-deleted post in this thread (excluding the one being deleted)
|
||||
|
||||
@@ -79,7 +79,7 @@ class ThreadMapper extends QBMapper {
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
)
|
||||
->orderBy('is_pinned', 'DESC')
|
||||
->addOrderBy('updated_at', 'DESC')
|
||||
->addOrderBy($qb->createFunction('COALESCE(last_reply_at, created_at)'), 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->setFirstResult($offset);
|
||||
return $this->findEntities($qb);
|
||||
@@ -97,7 +97,7 @@ class ThreadMapper extends QBMapper {
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
)
|
||||
->orderBy('is_pinned', 'DESC')
|
||||
->addOrderBy('updated_at', 'DESC');
|
||||
->addOrderBy($qb->createFunction('COALESCE(last_reply_at, created_at)'), 'DESC');
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ class ThreadMapper extends QBMapper {
|
||||
)
|
||||
)
|
||||
->orderBy('t.is_pinned', 'DESC')
|
||||
->addOrderBy('t.updated_at', 'DESC')
|
||||
->addOrderBy($qb->createFunction('COALESCE(t.last_reply_at, t.created_at)'), 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->setFirstResult($offset);
|
||||
|
||||
|
||||
@@ -225,7 +225,6 @@ class StatsService {
|
||||
$updateQb->update('forum_categories')
|
||||
->set('thread_count', $updateQb->createNamedParameter($threadCount, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
|
||||
->set('post_count', $updateQb->createNamedParameter($postCount, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
|
||||
->set('updated_at', $updateQb->createNamedParameter(time(), \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
|
||||
->where($updateQb->expr()->eq('id', $updateQb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
|
||||
$updateQb->executeStatement();
|
||||
}
|
||||
@@ -267,7 +266,7 @@ class StatsService {
|
||||
* @return void
|
||||
*/
|
||||
public function rebuildThreadStats(int $threadId): void {
|
||||
// Count non-deleted posts in this thread (excluding first post)
|
||||
// Count non-deleted reply posts in this thread (excluding first post)
|
||||
$postQb = $this->db->getQueryBuilder();
|
||||
$postQb->select($postQb->func()->count('*', 'count'))
|
||||
->from('forum_posts')
|
||||
@@ -278,11 +277,47 @@ class StatsService {
|
||||
$postCount = (int)($postResult->fetchOne() ?? 0);
|
||||
$postResult->closeCursor();
|
||||
|
||||
// Find the latest non-deleted post in this thread (for last_post_id)
|
||||
$lastPostQb = $this->db->getQueryBuilder();
|
||||
$lastPostQb->select('id', 'author_id', 'created_at', 'is_first_post')
|
||||
->from('forum_posts')
|
||||
->where($lastPostQb->expr()->eq('thread_id', $lastPostQb->createNamedParameter($threadId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($lastPostQb->expr()->isNull('deleted_at'))
|
||||
->orderBy('created_at', 'DESC')
|
||||
->setMaxResults(1);
|
||||
$lastPostResult = $lastPostQb->executeQuery();
|
||||
$lastPost = $lastPostResult->fetch();
|
||||
$lastPostResult->closeCursor();
|
||||
|
||||
// Find the latest non-deleted reply (for last_reply_at and last_reply_author_id)
|
||||
$lastReplyQb = $this->db->getQueryBuilder();
|
||||
$lastReplyQb->select('id', 'author_id', 'created_at')
|
||||
->from('forum_posts')
|
||||
->where($lastReplyQb->expr()->eq('thread_id', $lastReplyQb->createNamedParameter($threadId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($lastReplyQb->expr()->isNull('deleted_at'))
|
||||
->andWhere($lastReplyQb->expr()->eq('is_first_post', $lastReplyQb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL)))
|
||||
->orderBy('created_at', 'DESC')
|
||||
->setMaxResults(1);
|
||||
$lastReplyResult = $lastReplyQb->executeQuery();
|
||||
$lastReply = $lastReplyResult->fetch();
|
||||
$lastReplyResult->closeCursor();
|
||||
|
||||
// Update thread stats
|
||||
$updateQb = $this->db->getQueryBuilder();
|
||||
$updateQb->update('forum_threads')
|
||||
->set('post_count', $updateQb->createNamedParameter($postCount, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
|
||||
->set('updated_at', $updateQb->createNamedParameter(time(), \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
|
||||
->set('last_post_id', $updateQb->createNamedParameter(
|
||||
$lastPost ? (int)$lastPost['id'] : null,
|
||||
\OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT
|
||||
))
|
||||
->set('last_reply_author_id', $updateQb->createNamedParameter(
|
||||
$lastReply ? $lastReply['author_id'] : null,
|
||||
\OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR
|
||||
))
|
||||
->set('last_reply_at', $updateQb->createNamedParameter(
|
||||
$lastReply ? (int)$lastReply['created_at'] : null,
|
||||
\OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT
|
||||
))
|
||||
->where($updateQb->expr()->eq('id', $updateQb->createNamedParameter($threadId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
|
||||
$updateQb->executeStatement();
|
||||
}
|
||||
|
||||
@@ -204,13 +204,7 @@ export default defineComponent({
|
||||
return (this.$route.params.slug as string) || null
|
||||
},
|
||||
sortedThreads(): Thread[] {
|
||||
// Sort pinned threads first, then by updatedAt descending
|
||||
return [...this.threads].sort((a, b) => {
|
||||
if (a.isPinned !== b.isPinned) {
|
||||
return a.isPinned ? -1 : 1
|
||||
}
|
||||
return b.updatedAt - a.updatedAt
|
||||
})
|
||||
return this.threads
|
||||
},
|
||||
},
|
||||
created() {
|
||||
|
||||
@@ -867,6 +867,117 @@ class PostControllerTest extends TestCase {
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
}
|
||||
|
||||
public function testCreatePostDoesNotUpdateThreadUpdatedAt(): void {
|
||||
$threadId = 1;
|
||||
$content = 'New reply content';
|
||||
$userId = 'user1';
|
||||
$originalUpdatedAt = 1000000;
|
||||
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn($userId);
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
|
||||
$thread = new Thread();
|
||||
$thread->setId($threadId);
|
||||
$thread->setCategoryId(1);
|
||||
$thread->setPostCount(3);
|
||||
$thread->setCreatedAt(900000);
|
||||
$thread->setUpdatedAt($originalUpdatedAt);
|
||||
|
||||
$category = new Category();
|
||||
$category->setId(1);
|
||||
$category->setPostCount(5);
|
||||
|
||||
$createdPost = $this->createMockPost(10, $threadId, $userId, $content);
|
||||
|
||||
$this->postMapper->expects($this->once())
|
||||
->method('insert')
|
||||
->willReturn($createdPost);
|
||||
|
||||
$this->readMarkerMapper->method('createOrUpdate');
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('find')
|
||||
->willReturn($thread);
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturnCallback(function ($updatedThread) use ($originalUpdatedAt) {
|
||||
// updated_at should NOT be changed when a reply is created
|
||||
$this->assertEquals($originalUpdatedAt, $updatedThread->getUpdatedAt());
|
||||
return $updatedThread;
|
||||
});
|
||||
|
||||
$this->categoryMapper->method('find')->willReturn($category);
|
||||
$this->categoryMapper->method('update')->willReturn($category);
|
||||
$this->forumUserMapper->method('incrementPostCount');
|
||||
$this->notificationService->method('notifyThreadSubscribers');
|
||||
|
||||
$response = $this->controller->create($threadId, $content);
|
||||
|
||||
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus());
|
||||
}
|
||||
|
||||
public function testDestroyPostDoesNotUpdateThreadUpdatedAt(): void {
|
||||
$postId = 1;
|
||||
$userId = 'user1';
|
||||
$originalUpdatedAt = 1000000;
|
||||
|
||||
$post = $this->createMockPost($postId, 1, $userId, 'Test content');
|
||||
$post->setIsFirstPost(false);
|
||||
|
||||
$thread = new Thread();
|
||||
$thread->setId(1);
|
||||
$thread->setCategoryId(1);
|
||||
$thread->setPostCount(3);
|
||||
$thread->setLastPostId($postId);
|
||||
$thread->setUpdatedAt($originalUpdatedAt);
|
||||
|
||||
$category = new Category();
|
||||
$category->setId(1);
|
||||
$category->setPostCount(5);
|
||||
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn($userId);
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
|
||||
$this->permissionService->method('getCategoryIdFromPost')->willReturn(1);
|
||||
$this->permissionService->method('hasCategoryPermission')->willReturn(false);
|
||||
|
||||
$this->postMapper->expects($this->once())
|
||||
->method('find')
|
||||
->willReturn($post);
|
||||
|
||||
$this->postMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturn($post);
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('find')
|
||||
->willReturn($thread);
|
||||
|
||||
$lastPost = $this->createMockPost(2, 1, $userId, 'Previous reply');
|
||||
$this->postMapper->expects($this->once())
|
||||
->method('findLatestByThreadId')
|
||||
->willReturn($lastPost);
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturnCallback(function ($updatedThread) use ($originalUpdatedAt) {
|
||||
// updated_at should NOT be changed when a reply is deleted
|
||||
$this->assertEquals($originalUpdatedAt, $updatedThread->getUpdatedAt());
|
||||
return $updatedThread;
|
||||
});
|
||||
|
||||
$this->categoryMapper->method('find')->willReturn($category);
|
||||
$this->categoryMapper->method('update')->willReturn($category);
|
||||
$this->forumUserMapper->method('decrementPostCount');
|
||||
|
||||
$response = $this->controller->destroy($postId);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
}
|
||||
|
||||
public function testByThreadPaginatedReturnsPostsSuccessfully(): void {
|
||||
$threadId = 1;
|
||||
$page = 1;
|
||||
|
||||
116
tests/unit/Db/ThreadMapperTest.php
Normal file
116
tests/unit/Db/ThreadMapperTest.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Forum\Tests\Db;
|
||||
|
||||
use OCA\Forum\Db\Thread;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ThreadMapperTest extends TestCase {
|
||||
public function testThreadEntitySortFieldsExist(): void {
|
||||
$thread = new Thread();
|
||||
|
||||
// Verify the fields used for sorting exist and work
|
||||
$thread->setLastReplyAt(1700000000);
|
||||
$this->assertEquals(1700000000, $thread->getLastReplyAt());
|
||||
|
||||
$thread->setCreatedAt(1600000000);
|
||||
$this->assertEquals(1600000000, $thread->getCreatedAt());
|
||||
|
||||
// last_reply_at can be null (no replies yet)
|
||||
$thread->setLastReplyAt(null);
|
||||
$this->assertNull($thread->getLastReplyAt());
|
||||
}
|
||||
|
||||
public function testThreadSortOrderWithLastReplyAt(): void {
|
||||
// Thread with a recent reply should sort before a thread with an older reply
|
||||
$threadWithRecentReply = new Thread();
|
||||
$threadWithRecentReply->setCreatedAt(1000);
|
||||
$threadWithRecentReply->setLastReplyAt(3000);
|
||||
|
||||
$threadWithOldReply = new Thread();
|
||||
$threadWithOldReply->setCreatedAt(2000);
|
||||
$threadWithOldReply->setLastReplyAt(2500);
|
||||
|
||||
// COALESCE(last_reply_at, created_at) should be used for sorting:
|
||||
// threadWithRecentReply: COALESCE(3000, 1000) = 3000
|
||||
// threadWithOldReply: COALESCE(2500, 2000) = 2500
|
||||
$sortKeyRecent = $threadWithRecentReply->getLastReplyAt() ?? $threadWithRecentReply->getCreatedAt();
|
||||
$sortKeyOld = $threadWithOldReply->getLastReplyAt() ?? $threadWithOldReply->getCreatedAt();
|
||||
|
||||
$this->assertGreaterThan($sortKeyOld, $sortKeyRecent);
|
||||
}
|
||||
|
||||
public function testThreadSortOrderFallsBackToCreatedAtWhenNoReplies(): void {
|
||||
// Thread with no replies should use created_at for sorting
|
||||
$newerThread = new Thread();
|
||||
$newerThread->setCreatedAt(2000);
|
||||
$newerThread->setLastReplyAt(null);
|
||||
|
||||
$olderThread = new Thread();
|
||||
$olderThread->setCreatedAt(1000);
|
||||
$olderThread->setLastReplyAt(null);
|
||||
|
||||
// COALESCE(null, created_at) = created_at
|
||||
$sortKeyNewer = $newerThread->getLastReplyAt() ?? $newerThread->getCreatedAt();
|
||||
$sortKeyOlder = $olderThread->getLastReplyAt() ?? $olderThread->getCreatedAt();
|
||||
|
||||
$this->assertGreaterThan($sortKeyOlder, $sortKeyNewer);
|
||||
}
|
||||
|
||||
public function testThreadWithReplyOutranksNewerThreadWithoutReply(): void {
|
||||
// An older thread with a recent reply should sort before a newer thread with no replies
|
||||
$olderThreadWithReply = new Thread();
|
||||
$olderThreadWithReply->setCreatedAt(1000);
|
||||
$olderThreadWithReply->setLastReplyAt(5000);
|
||||
|
||||
$newerThreadNoReply = new Thread();
|
||||
$newerThreadNoReply->setCreatedAt(4000);
|
||||
$newerThreadNoReply->setLastReplyAt(null);
|
||||
|
||||
// COALESCE(5000, 1000) = 5000 vs COALESCE(null, 4000) = 4000
|
||||
$sortKeyOlderWithReply = $olderThreadWithReply->getLastReplyAt() ?? $olderThreadWithReply->getCreatedAt();
|
||||
$sortKeyNewerNoReply = $newerThreadNoReply->getLastReplyAt() ?? $newerThreadNoReply->getCreatedAt();
|
||||
|
||||
$this->assertGreaterThan($sortKeyNewerNoReply, $sortKeyOlderWithReply);
|
||||
}
|
||||
|
||||
public function testThreadSortOrderPinnedFirst(): void {
|
||||
// Pinned threads should always sort before non-pinned, regardless of timestamps
|
||||
$pinnedThread = new Thread();
|
||||
$pinnedThread->setIsPinned(true);
|
||||
$pinnedThread->setCreatedAt(1000);
|
||||
$pinnedThread->setLastReplyAt(null);
|
||||
|
||||
$unpinnedThread = new Thread();
|
||||
$unpinnedThread->setIsPinned(false);
|
||||
$unpinnedThread->setCreatedAt(9999);
|
||||
$unpinnedThread->setLastReplyAt(9999);
|
||||
|
||||
// Pinned threads always come first in the sort order
|
||||
$this->assertTrue($pinnedThread->getIsPinned());
|
||||
$this->assertFalse($unpinnedThread->getIsPinned());
|
||||
}
|
||||
|
||||
public function testThreadUpdatedAtIsIndependentOfSorting(): void {
|
||||
// updated_at should NOT affect thread listing order
|
||||
// A thread with a recent updated_at but old replies should sort by last_reply_at
|
||||
$recentlyUpdatedThread = new Thread();
|
||||
$recentlyUpdatedThread->setCreatedAt(1000);
|
||||
$recentlyUpdatedThread->setLastReplyAt(2000);
|
||||
$recentlyUpdatedThread->setUpdatedAt(9999); // recently updated (e.g. title edit)
|
||||
|
||||
$threadWithRecentReply = new Thread();
|
||||
$threadWithRecentReply->setCreatedAt(1500);
|
||||
$threadWithRecentReply->setLastReplyAt(5000);
|
||||
$threadWithRecentReply->setUpdatedAt(1500); // not recently updated
|
||||
|
||||
// Sort key uses COALESCE(last_reply_at, created_at), NOT updated_at
|
||||
$sortKeyUpdated = $recentlyUpdatedThread->getLastReplyAt() ?? $recentlyUpdatedThread->getCreatedAt();
|
||||
$sortKeyRecentReply = $threadWithRecentReply->getLastReplyAt() ?? $threadWithRecentReply->getCreatedAt();
|
||||
|
||||
// Thread with recent reply should sort first despite lower updated_at
|
||||
$this->assertGreaterThan($sortKeyUpdated, $sortKeyRecentReply);
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
0.29.1
|
||||
0.29.2
|
||||
|
||||
Reference in New Issue
Block a user