mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-17 17:28:02 +00:00
fix: category read status after thread creation
This commit is contained in:
@@ -13,6 +13,7 @@ use OCA\Forum\Db\DraftMapper;
|
||||
use OCA\Forum\Db\ForumUserMapper;
|
||||
use OCA\Forum\Db\Post;
|
||||
use OCA\Forum\Db\PostMapper;
|
||||
use OCA\Forum\Db\ReadMarkerMapper;
|
||||
use OCA\Forum\Db\Thread;
|
||||
use OCA\Forum\Db\ThreadMapper;
|
||||
use OCA\Forum\Db\ThreadSubscriptionMapper;
|
||||
@@ -42,6 +43,7 @@ class ThreadController extends OCSController {
|
||||
private ForumUserMapper $forumUserMapper,
|
||||
private ThreadSubscriptionMapper $threadSubscriptionMapper,
|
||||
private DraftMapper $draftMapper,
|
||||
private ReadMarkerMapper $readMarkerMapper,
|
||||
private ThreadEnrichmentService $threadEnrichmentService,
|
||||
private UserPreferencesService $userPreferencesService,
|
||||
private UserService $userService,
|
||||
@@ -285,6 +287,23 @@ class ThreadController extends OCSController {
|
||||
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Check if the category was already read before creating the thread
|
||||
// (used later to decide whether to update the category read marker)
|
||||
$wasCategoryRead = false;
|
||||
try {
|
||||
$lastActivity = $this->threadMapper->getLastActivityForCategory($categoryId);
|
||||
if ($lastActivity === null) {
|
||||
$wasCategoryRead = true;
|
||||
} else {
|
||||
$marker = $this->readMarkerMapper->findByUserAndCategory($user->getUID(), $categoryId);
|
||||
$wasCategoryRead = $marker->getReadAt() >= $lastActivity;
|
||||
}
|
||||
} catch (DoesNotExistException $e) {
|
||||
// No read marker means the category is unread
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to check category read state: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Generate slug from title
|
||||
$slug = $this->generateSlug($title);
|
||||
|
||||
@@ -369,6 +388,16 @@ class ThreadController extends OCSController {
|
||||
$this->logger->warning('Failed to send mention notifications: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Update category read marker so the category stays read,
|
||||
// but only if it was not already unread before this thread was created
|
||||
if ($wasCategoryRead) {
|
||||
try {
|
||||
$this->readMarkerMapper->createOrUpdateCategoryMarker($user->getUID(), $categoryId);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update category read marker: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Delete any draft for this category now that the thread is created
|
||||
try {
|
||||
$this->draftMapper->deleteThreadDraft($user->getUID(), $categoryId);
|
||||
|
||||
@@ -136,6 +136,27 @@ class ReadMarkerMapper extends QBMapper {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a category read marker for a user
|
||||
*
|
||||
* @throws DoesNotExistException
|
||||
*/
|
||||
public function findByUserAndCategory(string $userId, int $categoryId): ReadMarker {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->eq('marker_type', $qb->createNamedParameter(ReadMarker::TYPE_CATEGORY, IQueryBuilder::PARAM_STR))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->eq('entity_id', $qb->createNamedParameter($categoryId, IQueryBuilder::PARAM_INT))
|
||||
);
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all category read markers for a user
|
||||
*
|
||||
|
||||
@@ -249,6 +249,30 @@ class ThreadMapper extends QBMapper {
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last activity timestamp for a single category
|
||||
*
|
||||
* @return int|null The timestamp of the last post, or null if no activity
|
||||
*/
|
||||
public function getLastActivityForCategory(int $categoryId): ?int {
|
||||
$postsTable = Application::tableName('forum_posts');
|
||||
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->selectAlias($qb->func()->max('p.created_at'), 'last_activity')
|
||||
->from($this->getTableName(), 't')
|
||||
->innerJoin('t', $postsTable, 'p', $qb->expr()->eq('p.thread_id', 't.id'))
|
||||
->where($qb->expr()->eq('t.category_id', $qb->createNamedParameter($categoryId, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($qb->expr()->isNull('t.deleted_at'))
|
||||
->andWhere($qb->expr()->isNull('p.deleted_at'))
|
||||
->andWhere($qb->expr()->eq('t.is_hidden', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)));
|
||||
|
||||
$result = $qb->executeQuery();
|
||||
$row = $result->fetch();
|
||||
$result->closeCursor();
|
||||
|
||||
return $row && $row['last_activity'] !== null ? (int)$row['last_activity'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find recent threads in specified categories
|
||||
*
|
||||
|
||||
@@ -7884,7 +7884,8 @@
|
||||
"id",
|
||||
"displayName",
|
||||
"owner",
|
||||
"ownerDisplayName"
|
||||
"ownerDisplayName",
|
||||
"memberCount"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -7898,6 +7899,10 @@
|
||||
},
|
||||
"ownerDisplayName": {
|
||||
"type": "string"
|
||||
},
|
||||
"memberCount": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ use OCA\Forum\Db\ForumUser;
|
||||
use OCA\Forum\Db\ForumUserMapper;
|
||||
use OCA\Forum\Db\Post;
|
||||
use OCA\Forum\Db\PostMapper;
|
||||
use OCA\Forum\Db\ReadMarker;
|
||||
use OCA\Forum\Db\ReadMarkerMapper;
|
||||
use OCA\Forum\Db\Thread;
|
||||
use OCA\Forum\Db\ThreadMapper;
|
||||
use OCA\Forum\Db\ThreadSubscriptionMapper;
|
||||
@@ -51,6 +53,9 @@ class ThreadControllerTest extends TestCase {
|
||||
/** @var DraftMapper&MockObject */
|
||||
private DraftMapper $draftMapper;
|
||||
|
||||
/** @var ReadMarkerMapper&MockObject */
|
||||
private ReadMarkerMapper $readMarkerMapper;
|
||||
|
||||
/** @var ThreadEnrichmentService&MockObject */
|
||||
private ThreadEnrichmentService $threadEnrichmentService;
|
||||
|
||||
@@ -83,6 +88,7 @@ class ThreadControllerTest extends TestCase {
|
||||
$this->forumUserMapper = $this->createMock(ForumUserMapper::class);
|
||||
$this->threadSubscriptionMapper = $this->createMock(ThreadSubscriptionMapper::class);
|
||||
$this->draftMapper = $this->createMock(DraftMapper::class);
|
||||
$this->readMarkerMapper = $this->createMock(ReadMarkerMapper::class);
|
||||
$this->threadEnrichmentService = $this->createMock(ThreadEnrichmentService::class);
|
||||
$this->userPreferencesService = $this->createMock(UserPreferencesService::class);
|
||||
$this->userService = $this->createMock(UserService::class);
|
||||
@@ -111,6 +117,7 @@ class ThreadControllerTest extends TestCase {
|
||||
$this->forumUserMapper,
|
||||
$this->threadSubscriptionMapper,
|
||||
$this->draftMapper,
|
||||
$this->readMarkerMapper,
|
||||
$this->threadEnrichmentService,
|
||||
$this->userPreferencesService,
|
||||
$this->userService,
|
||||
@@ -1243,6 +1250,176 @@ class ThreadControllerTest extends TestCase {
|
||||
$this->assertEquals(0, $data['pagination']['total']);
|
||||
}
|
||||
|
||||
public function testCreateThreadUpdatesCategoryReadMarkerWhenCategoryWasRead(): void {
|
||||
$categoryId = 1;
|
||||
$title = 'New Thread';
|
||||
$content = 'Content';
|
||||
$userId = 'user1';
|
||||
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn($userId);
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
$this->userPreferencesService->method('getPreference')->willReturn(false);
|
||||
|
||||
// Category had activity at time 1000, user read it at 1000 — category was read
|
||||
$this->threadMapper->method('getLastActivityForCategory')
|
||||
->with($categoryId)
|
||||
->willReturn(1000);
|
||||
|
||||
$categoryMarker = new ReadMarker();
|
||||
$categoryMarker->setReadAt(1000);
|
||||
$this->readMarkerMapper->method('findByUserAndCategory')
|
||||
->with($userId, $categoryId)
|
||||
->willReturn($categoryMarker);
|
||||
|
||||
// Expect category read marker to be updated
|
||||
$this->readMarkerMapper->expects($this->once())
|
||||
->method('createOrUpdateCategoryMarker')
|
||||
->with($userId, $categoryId);
|
||||
|
||||
// Standard create mocks
|
||||
$this->threadMapper->method('findBySlug')->willThrowException(new DoesNotExistException(''));
|
||||
$thread = $this->createMockThread(1, $categoryId, $userId, $title);
|
||||
$this->threadMapper->method('insert')->willReturn($thread);
|
||||
$post = new Post();
|
||||
$post->setId(1);
|
||||
$this->postMapper->method('insert')->willReturn($post);
|
||||
$this->threadMapper->method('update')->willReturn($thread);
|
||||
$category = new Category();
|
||||
$category->setThreadCount(0);
|
||||
$category->setPostCount(0);
|
||||
$this->categoryMapper->method('find')->willReturn($category);
|
||||
$this->categoryMapper->method('update')->willReturn($category);
|
||||
|
||||
$response = $this->controller->create($categoryId, $title, $content);
|
||||
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus());
|
||||
}
|
||||
|
||||
public function testCreateThreadDoesNotUpdateCategoryReadMarkerWhenCategoryWasUnread(): void {
|
||||
$categoryId = 1;
|
||||
$title = 'New Thread';
|
||||
$content = 'Content';
|
||||
$userId = 'user1';
|
||||
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn($userId);
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
$this->userPreferencesService->method('getPreference')->willReturn(false);
|
||||
|
||||
// Category had activity at time 2000, user read it at 1000 — category was unread
|
||||
$this->threadMapper->method('getLastActivityForCategory')
|
||||
->with($categoryId)
|
||||
->willReturn(2000);
|
||||
|
||||
$categoryMarker = new ReadMarker();
|
||||
$categoryMarker->setReadAt(1000);
|
||||
$this->readMarkerMapper->method('findByUserAndCategory')
|
||||
->with($userId, $categoryId)
|
||||
->willReturn($categoryMarker);
|
||||
|
||||
// Should NOT update category read marker
|
||||
$this->readMarkerMapper->expects($this->never())
|
||||
->method('createOrUpdateCategoryMarker');
|
||||
|
||||
// Standard create mocks
|
||||
$this->threadMapper->method('findBySlug')->willThrowException(new DoesNotExistException(''));
|
||||
$thread = $this->createMockThread(1, $categoryId, $userId, $title);
|
||||
$this->threadMapper->method('insert')->willReturn($thread);
|
||||
$post = new Post();
|
||||
$post->setId(1);
|
||||
$this->postMapper->method('insert')->willReturn($post);
|
||||
$this->threadMapper->method('update')->willReturn($thread);
|
||||
$category = new Category();
|
||||
$category->setThreadCount(0);
|
||||
$category->setPostCount(0);
|
||||
$this->categoryMapper->method('find')->willReturn($category);
|
||||
$this->categoryMapper->method('update')->willReturn($category);
|
||||
|
||||
$response = $this->controller->create($categoryId, $title, $content);
|
||||
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus());
|
||||
}
|
||||
|
||||
public function testCreateThreadDoesNotUpdateCategoryReadMarkerWhenNoMarkerExists(): void {
|
||||
$categoryId = 1;
|
||||
$title = 'New Thread';
|
||||
$content = 'Content';
|
||||
$userId = 'user1';
|
||||
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn($userId);
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
$this->userPreferencesService->method('getPreference')->willReturn(false);
|
||||
|
||||
// Category has activity but user has no read marker — category is unread
|
||||
$this->threadMapper->method('getLastActivityForCategory')
|
||||
->with($categoryId)
|
||||
->willReturn(1000);
|
||||
|
||||
$this->readMarkerMapper->method('findByUserAndCategory')
|
||||
->with($userId, $categoryId)
|
||||
->willThrowException(new DoesNotExistException(''));
|
||||
|
||||
// Should NOT update category read marker
|
||||
$this->readMarkerMapper->expects($this->never())
|
||||
->method('createOrUpdateCategoryMarker');
|
||||
|
||||
// Standard create mocks
|
||||
$this->threadMapper->method('findBySlug')->willThrowException(new DoesNotExistException(''));
|
||||
$thread = $this->createMockThread(1, $categoryId, $userId, $title);
|
||||
$this->threadMapper->method('insert')->willReturn($thread);
|
||||
$post = new Post();
|
||||
$post->setId(1);
|
||||
$this->postMapper->method('insert')->willReturn($post);
|
||||
$this->threadMapper->method('update')->willReturn($thread);
|
||||
$category = new Category();
|
||||
$category->setThreadCount(0);
|
||||
$category->setPostCount(0);
|
||||
$this->categoryMapper->method('find')->willReturn($category);
|
||||
$this->categoryMapper->method('update')->willReturn($category);
|
||||
|
||||
$response = $this->controller->create($categoryId, $title, $content);
|
||||
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus());
|
||||
}
|
||||
|
||||
public function testCreateThreadUpdatesCategoryReadMarkerWhenCategoryHadNoActivity(): void {
|
||||
$categoryId = 1;
|
||||
$title = 'New Thread';
|
||||
$content = 'Content';
|
||||
$userId = 'user1';
|
||||
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn($userId);
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
$this->userPreferencesService->method('getPreference')->willReturn(false);
|
||||
|
||||
// No prior activity in category
|
||||
$this->threadMapper->method('getLastActivityForCategory')
|
||||
->with($categoryId)
|
||||
->willReturn(null);
|
||||
|
||||
// Should update category read marker (empty category stays read)
|
||||
$this->readMarkerMapper->expects($this->once())
|
||||
->method('createOrUpdateCategoryMarker')
|
||||
->with($userId, $categoryId);
|
||||
|
||||
// Standard create mocks
|
||||
$this->threadMapper->method('findBySlug')->willThrowException(new DoesNotExistException(''));
|
||||
$thread = $this->createMockThread(1, $categoryId, $userId, $title);
|
||||
$this->threadMapper->method('insert')->willReturn($thread);
|
||||
$post = new Post();
|
||||
$post->setId(1);
|
||||
$this->postMapper->method('insert')->willReturn($post);
|
||||
$this->threadMapper->method('update')->willReturn($thread);
|
||||
$category = new Category();
|
||||
$category->setThreadCount(0);
|
||||
$category->setPostCount(0);
|
||||
$this->categoryMapper->method('find')->willReturn($category);
|
||||
$this->categoryMapper->method('update')->willReturn($category);
|
||||
|
||||
$response = $this->controller->create($categoryId, $title, $content);
|
||||
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus());
|
||||
}
|
||||
|
||||
private function createMockThread(int $id, int $categoryId, string $authorId, string $title): Thread {
|
||||
$thread = new Thread();
|
||||
$thread->setId($id);
|
||||
|
||||
Reference in New Issue
Block a user