diff --git a/lib/Controller/PostController.php b/lib/Controller/PostController.php index 98fe835..acad3b4 100644 --- a/lib/Controller/PostController.php +++ b/lib/Controller/PostController.php @@ -401,6 +401,8 @@ class PostController extends OCSController { $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) { @@ -561,9 +563,19 @@ class PostController extends OCSController { $latestPost = $this->postMapper->findLatestByThreadId($thread->getId(), $post->getId()); if ($latestPost) { $thread->setLastPostId($latestPost->getId()); + if (!$latestPost->getIsFirstPost()) { + $thread->setLastReplyAuthorId($latestPost->getAuthorId()); + $thread->setLastReplyAt($latestPost->getCreatedAt()); + } else { + // Only the first post remains — no replies + $thread->setLastReplyAuthorId(null); + $thread->setLastReplyAt(null); + } } else { // No other posts in thread, set to null (or keep first post ID) $thread->setLastPostId(null); + $thread->setLastReplyAuthorId(null); + $thread->setLastReplyAt(null); } } diff --git a/lib/Controller/ThreadController.php b/lib/Controller/ThreadController.php index 4779fbd..408f8b1 100644 --- a/lib/Controller/ThreadController.php +++ b/lib/Controller/ThreadController.php @@ -151,15 +151,26 @@ class ThreadController extends OCSController { // Fetch threads for the current page $threads = $this->threadMapper->findByCategoryId($categoryId, $perPage, $offset); - // Extract unique author IDs - $authorIds = array_unique(array_map(fn ($t) => $t->getAuthorId(), $threads)); + // Extract unique author IDs (thread authors + last reply authors) + $authorIds = array_map(fn ($t) => $t->getAuthorId(), $threads); + $lastReplyAuthorIds = array_filter(array_map(fn ($t) => $t->getLastReplyAuthorId(), $threads)); + $allAuthorIds = array_unique(array_merge($authorIds, $lastReplyAuthorIds)); // Batch fetch author data (includes roles) - $authors = $this->userService->enrichMultipleUsers($authorIds); + $authors = $this->userService->enrichMultipleUsers($allAuthorIds); - // Enrich threads with pre-fetched author data + // Enrich threads with pre-fetched author data and last reply info $enrichedThreads = array_map(function ($t) use ($authors) { - return $this->threadEnrichmentService->enrichThread($t, $authors[$t->getAuthorId()] ?? null); + $lastReply = null; + $lastReplyAuthorId = $t->getLastReplyAuthorId(); + if ($lastReplyAuthorId !== null) { + $lastReply = [ + 'postId' => $t->getLastPostId(), + 'author' => $authors[$lastReplyAuthorId] ?? null, + 'createdAt' => $t->getLastReplyAt(), + ]; + } + return $this->threadEnrichmentService->enrichThread($t, $authors[$t->getAuthorId()] ?? null, $lastReply); }, $threads); return new DataResponse([ diff --git a/lib/Db/PostMapper.php b/lib/Db/PostMapper.php index 4b1ed6d..8511b4c 100644 --- a/lib/Db/PostMapper.php +++ b/lib/Db/PostMapper.php @@ -93,6 +93,25 @@ class PostMapper extends QBMapper { return $this->findEntities($qb); } + /** + * Find posts by multiple IDs + * + * @param array $ids + * @return array + */ + public function findByIds(array $ids): array { + if (empty($ids)) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->in('id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY))) + ->andWhere($qb->expr()->isNull('deleted_at')); + return $this->findEntities($qb); + } + /** * @return array */ diff --git a/lib/Db/Thread.php b/lib/Db/Thread.php index 0012aae..22dbd7f 100644 --- a/lib/Db/Thread.php +++ b/lib/Db/Thread.php @@ -28,6 +28,10 @@ use OCP\AppFramework\Db\Entity; * @method void setPostCount(int $value) * @method int|null getLastPostId() * @method void setLastPostId(?int $value) + * @method string|null getLastReplyAuthorId() + * @method void setLastReplyAuthorId(?string $value) + * @method int|null getLastReplyAt() + * @method void setLastReplyAt(?int $value) * @method bool getIsLocked() * @method void setIsLocked(bool $value) * @method bool getIsPinned() @@ -49,6 +53,8 @@ class Thread extends Entity implements JsonSerializable { protected $viewCount; protected $postCount; protected $lastPostId; + protected $lastReplyAuthorId; + protected $lastReplyAt; protected $isLocked; protected $isPinned; protected $isHidden; @@ -65,6 +71,8 @@ class Thread extends Entity implements JsonSerializable { $this->addType('viewCount', 'integer'); $this->addType('postCount', 'integer'); $this->addType('lastPostId', 'integer'); + $this->addType('lastReplyAuthorId', 'string'); + $this->addType('lastReplyAt', 'integer'); $this->addType('isLocked', 'boolean'); $this->addType('isPinned', 'boolean'); $this->addType('isHidden', 'boolean'); @@ -83,6 +91,8 @@ class Thread extends Entity implements JsonSerializable { 'viewCount' => $this->getViewCount(), 'postCount' => $this->getPostCount(), 'lastPostId' => $this->getLastPostId(), + 'lastReplyAuthorId' => $this->getLastReplyAuthorId(), + 'lastReplyAt' => $this->getLastReplyAt(), 'isLocked' => $this->getIsLocked(), 'isPinned' => $this->getIsPinned(), 'isHidden' => $this->getIsHidden(), diff --git a/lib/Migration/Version26Date20260321000000.php b/lib/Migration/Version26Date20260321000000.php new file mode 100644 index 0000000..f211660 --- /dev/null +++ b/lib/Migration/Version26Date20260321000000.php @@ -0,0 +1,83 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Forum\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\DB\Types; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Version 26 Migration: + * - Add last_reply_author_id and last_reply_at columns to forum_threads + * - Backfill from existing posts data + */ +class Version26Date20260321000000 extends SimpleMigrationStep { + public function __construct( + private IDBConnection $db, + ) { + } + + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('forum_threads')) { + return null; + } + + $table = $schema->getTable('forum_threads'); + + if (!$table->hasColumn('last_reply_author_id')) { + $table->addColumn('last_reply_author_id', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + } + + if (!$table->hasColumn('last_reply_at')) { + $table->addColumn('last_reply_at', Types::BIGINT, [ + 'notnull' => false, + 'unsigned' => true, + ]); + } + + return $schema; + } + + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $output->info('Forum: Backfilling last_reply_author_id and last_reply_at from posts …'); + + // Find threads that have a last_post_id pointing to a non-first-post + $qb = $this->db->getQueryBuilder(); + $qb->select('t.id', 'p.author_id', 'p.created_at') + ->from('forum_threads', 't') + ->innerJoin('t', 'forum_posts', 'p', $qb->expr()->eq('t.last_post_id', 'p.id')) + ->where($qb->expr()->eq('p.is_first_post', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) + ->andWhere($qb->expr()->isNull('p.deleted_at')) + ->andWhere($qb->expr()->isNull('t.deleted_at')); + $result = $qb->executeQuery(); + + $count = 0; + while ($row = $result->fetch()) { + $update = $this->db->getQueryBuilder(); + $update->update('forum_threads') + ->set('last_reply_author_id', $update->createNamedParameter($row['author_id'])) + ->set('last_reply_at', $update->createNamedParameter((int)$row['created_at'], IQueryBuilder::PARAM_INT)) + ->where($update->expr()->eq('id', $update->createNamedParameter((int)$row['id'], IQueryBuilder::PARAM_INT))); + $update->executeStatement(); + $count++; + } + $result->closeCursor(); + + $output->info("Forum: Backfilled last reply info for $count threads."); + } +} diff --git a/lib/Service/ThreadEnrichmentService.php b/lib/Service/ThreadEnrichmentService.php index 8476734..ae78699 100644 --- a/lib/Service/ThreadEnrichmentService.php +++ b/lib/Service/ThreadEnrichmentService.php @@ -33,7 +33,7 @@ class ThreadEnrichmentService { * @param array|null $author Optional pre-loaded author data * @return array Enriched thread data */ - public function enrichThread(mixed $thread, ?array $author = null): array { + public function enrichThread(mixed $thread, ?array $author = null, ?array $lastReply = null): array { if (!is_array($thread)) { $thread = $thread->jsonSerialize(); } @@ -45,6 +45,9 @@ class ThreadEnrichmentService { $thread['author'] = $author; } + // Add last reply info (author + timestamp) + $thread['lastReply'] = $lastReply; + // Add category information (slug and name) for navigation try { $category = $this->categoryMapper->find($thread['categoryId']); diff --git a/src/components/ThreadCard/ThreadCard.test.ts b/src/components/ThreadCard/ThreadCard.test.ts index 7c67aaf..3b4f4b5 100644 --- a/src/components/ThreadCard/ThreadCard.test.ts +++ b/src/components/ThreadCard/ThreadCard.test.ts @@ -133,4 +133,82 @@ describe('ThreadCard', () => { expect(statValues[1]!.text()).toBe('0') }) }) + + describe('last reply', () => { + it('should show last reply info when thread has a last reply', () => { + const thread = createMockThread({ + lastPostId: 42, + lastReplyAuthorId: 'alice', + lastReplyAt: 1700000000, + lastReply: { + postId: 42, + author: { + userId: 'alice', + displayName: 'Alice', + isDeleted: false, + roles: [], + signature: null, + signatureRaw: null, + }, + createdAt: 1700000000, + }, + }) + const wrapper = mount(ThreadCard, { + props: { thread }, + }) + const lastReply = wrapper.find('.last-reply') + expect(lastReply.exists()).toBe(true) + expect(lastReply.text()).toContain('Alice') + }) + + it('should not show last reply when thread has no last reply', () => { + const thread = createMockThread({ lastReply: undefined }) + const wrapper = mount(ThreadCard, { + props: { thread }, + }) + expect(wrapper.find('.last-reply').exists()).toBe(false) + }) + + it('should emit navigate-last-reply when last reply link is clicked', async () => { + const thread = createMockThread({ + lastPostId: 42, + lastReplyAuthorId: 'alice', + lastReplyAt: 1700000000, + lastReply: { + postId: 42, + author: { + userId: 'alice', + displayName: 'Alice', + isDeleted: false, + roles: [], + signature: null, + signatureRaw: null, + }, + createdAt: 1700000000, + }, + }) + const wrapper = mount(ThreadCard, { + props: { thread }, + }) + await wrapper.find('.last-reply').trigger('click') + expect(wrapper.emitted('navigate-last-reply')).toBeTruthy() + }) + + it('should fall back to lastReplyAuthorId when author displayName is unavailable', () => { + const thread = createMockThread({ + lastPostId: 42, + lastReplyAuthorId: 'bob', + lastReplyAt: 1700000000, + lastReply: { + postId: 42, + author: null, + createdAt: 1700000000, + }, + }) + const wrapper = mount(ThreadCard, { + props: { thread }, + }) + expect(wrapper.find('.last-reply').text()).toContain('bob') + }) + }) }) diff --git a/src/components/ThreadCard/ThreadCard.vue b/src/components/ThreadCard/ThreadCard.vue index a864700..6458f78 100644 --- a/src/components/ThreadCard/ThreadCard.vue +++ b/src/components/ThreadCard/ThreadCard.vue @@ -33,6 +33,21 @@ + @@ -87,6 +102,12 @@ export default defineComponent({ default: false, }, }, + emits: ['navigate-last-reply'], + computed: { + lastReplyUrl(): string { + return `/apps/forum/t/${this.thread.slug}?page=last&post=${this.thread.lastPostId}` + }, + }, data() { return { strings: { @@ -95,6 +116,7 @@ export default defineComponent({ pinned: t('forum', 'Pinned thread'), locked: t('forum', 'Locked thread'), unread: t('forum', 'Unread'), + lastReplyBy: (name: string) => t('forum', 'Last reply by {name}', { name }), }, } }, @@ -265,6 +287,22 @@ export default defineComponent({ font-size: 0.65rem; } } + + .meta-divider { + color: var(--color-text-maxcontrast); + } + + .last-reply { + font-size: 0.8rem; + color: var(--color-text-maxcontrast); + text-decoration: none; + white-space: nowrap; + + &:hover { + color: var(--color-primary-element); + text-decoration: underline; + } + } } @media (max-width: 768px) { diff --git a/src/test-mocks.ts b/src/test-mocks.ts index 6db588e..b910c9a 100644 --- a/src/test-mocks.ts +++ b/src/test-mocks.ts @@ -67,6 +67,8 @@ export function createMockThread(overrides: Partial = {}): Thread { viewCount: 100, postCount: 10, lastPostId: null, + lastReplyAuthorId: null, + lastReplyAt: null, isLocked: false, isPinned: false, isHidden: false, diff --git a/src/types/models.ts b/src/types/models.ts index ec1e7f8..ccc39b6 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -54,6 +54,8 @@ export interface Thread { viewCount: number postCount: number lastPostId: number | null + lastReplyAuthorId: string | null + lastReplyAt: number | null isLocked: boolean isPinned: boolean isHidden: boolean @@ -65,6 +67,11 @@ export interface Thread { categoryName?: string | null isSubscribed?: boolean isBookmarked?: boolean + lastReply?: { + postId: number + author: User | null + createdAt: number + } | null } export interface Post { diff --git a/src/views/CategoryView.vue b/src/views/CategoryView.vue index 135db7e..48d12e2 100644 --- a/src/views/CategoryView.vue +++ b/src/views/CategoryView.vue @@ -110,6 +110,7 @@ :thread="thread" :is-unread="isThreadUnread(thread)" @click="navigateToThread(thread)" + @navigate-last-reply="navigateToLastReply(thread)" /> @@ -371,6 +372,14 @@ export default defineComponent({ this.$router.push(`/t/${thread.slug}`) }, + navigateToLastReply(thread: Thread) { + const query: Record = { page: 'last' } + if (thread.lastPostId) { + query.post = String(thread.lastPostId) + } + this.$router.push({ path: `/t/${thread.slug}`, query }) + }, + createThread() { // Redirect guests to login only if they cannot post if (this.userId === null && !this.canPost) { diff --git a/src/views/ThreadView.vue b/src/views/ThreadView.vue index 7393c73..d88e8ba 100644 --- a/src/views/ThreadView.vue +++ b/src/views/ThreadView.vue @@ -497,10 +497,15 @@ export default defineComponent({ // Allow if user is the author, or has moderation permissions return this.thread?.authorId === this.userId || this.canModerate }, + // Whether ?page=last was requested + isLastPageRequested(): boolean { + return this.$route.query.page === 'last' + }, // Get page from query param pageFromQuery(): number | null { const page = this.$route.query.page if (page) { + if (page === 'last') return null // handled separately const parsed = parseInt(page as string) return isNaN(parsed) ? null : parsed } @@ -568,7 +573,8 @@ export default defineComponent({ } // Fetch posts - use page from query param if present - const initialPage = this.pageFromQuery || 0 + // page=last → use a high number so backend clamps to last page + const initialPage = this.isLastPageRequested ? 999999 : this.pageFromQuery || 0 await this.fetchPosts(initialPage) // Check permissions await this.checkPermissions() diff --git a/tests/unit/Controller/PostControllerTest.php b/tests/unit/Controller/PostControllerTest.php index eeb6ac5..574f9fb 100644 --- a/tests/unit/Controller/PostControllerTest.php +++ b/tests/unit/Controller/PostControllerTest.php @@ -614,9 +614,12 @@ class PostControllerTest extends TestCase { $this->threadMapper->expects($this->once()) ->method('update') - ->willReturnCallback(function ($updatedThread) { + ->willReturnCallback(function ($updatedThread) use ($userId, $createdPost) { // Thread post count should be incremented from 5 to 6 $this->assertEquals(6, $updatedThread->getPostCount()); + // Last reply info should be set + $this->assertEquals($userId, $updatedThread->getLastReplyAuthorId()); + $this->assertEquals($createdPost->getCreatedAt(), $updatedThread->getLastReplyAt()); return $updatedThread; }); @@ -683,9 +686,12 @@ class PostControllerTest extends TestCase { $this->threadMapper->expects($this->once()) ->method('update') - ->willReturnCallback(function ($updatedThread) { + ->willReturnCallback(function ($updatedThread) use ($userId) { // Thread post count should be decremented from 5 to 4 $this->assertEquals(4, $updatedThread->getPostCount()); + // Last reply info should be updated to the new last post + $this->assertEquals($userId, $updatedThread->getLastReplyAuthorId()); + $this->assertNotNull($updatedThread->getLastReplyAt()); return $updatedThread; }); @@ -789,6 +795,78 @@ class PostControllerTest extends TestCase { $this->assertEquals(Http::STATUS_OK, $response->getStatus()); } + public function testDestroyLastReplyClearsLastReplyFieldsWhenOnlyFirstPostRemains(): void { + $postId = 5; + $userId = 'user1'; + $post = $this->createMockPost($postId, 1, $userId, 'The only reply'); + $post->setIsFirstPost(false); + + $thread = new Thread(); + $thread->setId(1); + $thread->setCategoryId(1); + $thread->setPostCount(1); + $thread->setLastPostId($postId); + $thread->setLastReplyAuthorId($userId); + $thread->setLastReplyAt(1700000000); + + $category = new Category(); + $category->setId(1); + $category->setPostCount(10); + + $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); + + // Only the first post remains after deletion + $firstPost = $this->createMockPost(1, 1, $userId, 'First post'); + $firstPost->setIsFirstPost(true); + $this->postMapper->expects($this->once()) + ->method('findLatestByThreadId') + ->with(1, $postId) + ->willReturn($firstPost); + + $this->threadMapper->expects($this->once()) + ->method('update') + ->willReturnCallback(function ($updatedThread) { + // Last reply fields should be cleared + $this->assertNull($updatedThread->getLastReplyAuthorId()); + $this->assertNull($updatedThread->getLastReplyAt()); + $this->assertEquals(1, $updatedThread->getLastPostId()); // Set to first post + return $updatedThread; + }); + + $this->categoryMapper->expects($this->once()) + ->method('find') + ->willReturn($category); + + $this->categoryMapper->expects($this->once()) + ->method('update') + ->willReturn($category); + + $this->forumUserMapper->expects($this->once()) + ->method('decrementPostCount') + ->with($userId); + + $response = $this->controller->destroy($postId); + + $this->assertEquals(Http::STATUS_OK, $response->getStatus()); + } + public function testByThreadPaginatedReturnsPostsSuccessfully(): void { $threadId = 1; $page = 1; diff --git a/tests/unit/Controller/ThreadControllerTest.php b/tests/unit/Controller/ThreadControllerTest.php index 8513083..ba9894e 100644 --- a/tests/unit/Controller/ThreadControllerTest.php +++ b/tests/unit/Controller/ThreadControllerTest.php @@ -104,12 +104,13 @@ class ThreadControllerTest extends TestCase { // Mock thread enrichment service to return serialized thread with mock data $this->threadEnrichmentService->method('enrichThread') - ->willReturnCallback(function ($thread) { + ->willReturnCallback(function ($thread, $author = null, $lastReply = null) { $data = is_array($thread) ? $thread : $thread->jsonSerialize(); - $data['author'] = ['userId' => $data['authorId'], 'displayName' => 'Test User']; + $data['author'] = $author ?? ['userId' => $data['authorId'], 'displayName' => 'Test User']; $data['categorySlug'] = 'test-category'; $data['categoryName'] = 'Test Category'; $data['isSubscribed'] = false; + $data['lastReply'] = $lastReply; return $data; }); @@ -1256,6 +1257,79 @@ class ThreadControllerTest extends TestCase { $this->assertEquals(0, $data['pagination']['total']); } + public function testByCategoryPaginatedEnrichesLastReplyData(): void { + $categoryId = 1; + $page = 1; + $perPage = 20; + + $thread = $this->createMockThread(1, $categoryId, 'user1', 'Thread With Reply'); + $thread->setLastPostId(10); + $thread->setLastReplyAuthorId('user2'); + $thread->setLastReplyAt(1700000000); + + $this->threadMapper->expects($this->once()) + ->method('countByCategoryId') + ->with($categoryId) + ->willReturn(1); + + $this->threadMapper->expects($this->once()) + ->method('findByCategoryId') + ->with($categoryId, $perPage, 0) + ->willReturn([$thread]); + + $this->userService->expects($this->once()) + ->method('enrichMultipleUsers') + ->willReturn([ + 'user1' => ['userId' => 'user1', 'displayName' => 'User 1'], + 'user2' => ['userId' => 'user2', 'displayName' => 'User 2'], + ]); + + $response = $this->controller->byCategoryPaginated($categoryId, $page, $perPage); + + $this->assertEquals(Http::STATUS_OK, $response->getStatus()); + $data = $response->getData(); + $this->assertCount(1, $data['threads']); + + $enrichedThread = $data['threads'][0]; + $this->assertNotNull($enrichedThread['lastReply']); + $this->assertEquals(10, $enrichedThread['lastReply']['postId']); + $this->assertEquals('user2', $enrichedThread['lastReply']['author']['userId']); + $this->assertEquals(1700000000, $enrichedThread['lastReply']['createdAt']); + } + + public function testByCategoryPaginatedOmitsLastReplyWhenNoReplies(): void { + $categoryId = 1; + $page = 1; + $perPage = 20; + + // Thread with no replies (lastReplyAuthorId is null) + $thread = $this->createMockThread(1, $categoryId, 'user1', 'Thread Without Reply'); + $thread->setLastPostId(1); + + $this->threadMapper->expects($this->once()) + ->method('countByCategoryId') + ->with($categoryId) + ->willReturn(1); + + $this->threadMapper->expects($this->once()) + ->method('findByCategoryId') + ->with($categoryId, $perPage, 0) + ->willReturn([$thread]); + + $this->userService->expects($this->once()) + ->method('enrichMultipleUsers') + ->willReturn([ + 'user1' => ['userId' => 'user1', 'displayName' => 'User 1'], + ]); + + $response = $this->controller->byCategoryPaginated($categoryId, $page, $perPage); + + $this->assertEquals(Http::STATUS_OK, $response->getStatus()); + $data = $response->getData(); + $enrichedThread = $data['threads'][0]; + $this->assertNull($enrichedThread['lastReply']); + } + public function testCreateThreadUpdatesCategoryReadMarkerWhenCategoryWasRead(): void { $categoryId = 1; $title = 'New Thread';