Compare commits

..

3 Commits

Author SHA1 Message Date
a1671baf2d chore(master): release 0.2.1 2025-11-17 18:15:40 +02:00
71ee133ac6 fix: unread counts for deleted posts 2025-11-17 18:13:19 +02:00
1add8db287 fix: thread card hover styles 2025-11-17 17:52:34 +02:00
9 changed files with 142 additions and 61 deletions

View File

@@ -1 +1 @@
{".":"0.2.0"}
{".":"0.2.1"}

View File

@@ -1,5 +1,13 @@
# Changelog
## [0.2.1](https://github.com/chenasraf/nextcloud-forum/compare/v0.2.0...v0.2.1) (2025-11-17)
### Bug Fixes
* thread card hover styles ([1add8db](https://github.com/chenasraf/nextcloud-forum/commit/1add8db28775d2d13d8b2eb9428a90eb99b32ae8))
* unread counts for deleted posts ([71ee133](https://github.com/chenasraf/nextcloud-forum/commit/71ee133ac6b59f9005918594f7e668031b8224fa))
## [0.2.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.1.7...v0.2.0) (2025-11-17)

View File

@@ -36,7 +36,7 @@ This app is in early stages of development. While functional, you may encounter
The forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control.
]]></description>
<version>0.2.0</version>
<version>0.2.1</version>
<licence>agpl</licence>
<author mail="contact@casraf.dev" homepage="https://casraf.dev">Chen Asraf</author>
<namespace>Forum</namespace>

View File

@@ -367,17 +367,53 @@ class PostController extends OCSController {
$post->setUpdatedAt(time());
$this->postMapper->update($post);
// Update thread post count
// Update thread post count and lastPostId
try {
$thread = $this->threadMapper->find($post->getThreadId());
$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)
$latestPost = $this->postMapper->findLatestByThreadId($thread->getId(), $post->getId());
if ($latestPost) {
$thread->setLastPostId($latestPost->getId());
} else {
// No other posts in thread, set to null (or keep first post ID)
$thread->setLastPostId(null);
}
}
$this->threadMapper->update($thread);
} catch (\Exception $e) {
$this->logger->warning('Failed to update thread post count after post deletion: ' . $e->getMessage());
$this->logger->warning('Failed to update thread after post deletion: ' . $e->getMessage());
// Don't fail the request if thread update fails
}
// Update user stats - decrement post count, and thread count if it's the first post
try {
$this->userStatsMapper->decrementPostCount($post->getAuthorId());
// If this is the first post of a thread, also decrement thread count
if ($post->getIsFirstPost()) {
$this->userStatsMapper->decrementThreadCount($post->getAuthorId());
}
} catch (\Exception $e) {
$this->logger->warning('Failed to update user stats after post deletion: ' . $e->getMessage());
// Don't fail the request if stats update fails
}
// Update category post count
try {
$category = $this->categoryMapper->find($categoryId);
$category->setPostCount(max(0, $category->getPostCount() - 1));
$this->categoryMapper->update($category);
} catch (\Exception $e) {
$this->logger->warning('Failed to update category post count after post deletion: ' . $e->getMessage());
// Don't fail the request if category update fails
}
return new DataResponse(['success' => true]);
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Post not found'], Http::STATUS_NOT_FOUND);

View File

@@ -396,6 +396,18 @@ class ThreadController extends OCSController {
// Don't fail the request if category update fails
}
// Update author's user stats (decrement thread count and all posts in this thread)
try {
$this->userStatsMapper->decrementThreadCount($thread->getAuthorId());
// Decrement post count by the number of posts in this thread
if ($thread->getPostCount() > 0) {
$this->userStatsMapper->decrementPostCount($thread->getAuthorId(), $thread->getPostCount());
}
} catch (\Exception $e) {
$this->logger->warning('Failed to update user stats after thread deletion: ' . $e->getMessage());
// Don't fail the request if stats update fails
}
return new DataResponse([
'success' => true,
'categorySlug' => $categorySlug,

View File

@@ -87,22 +87,26 @@ class PostMapper extends QBMapper {
public function findByAuthorId(string $authorId, int $limit = 50, int $offset = 0, bool $excludeFirstPosts = false): array {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
$qb->select('p.*')
->from($this->getTableName(), 'p')
->innerJoin('p', 'forum_threads', 't', $qb->expr()->eq('p.thread_id', 't.id'))
->where(
$qb->expr()->eq('author_id', $qb->createNamedParameter($authorId, IQueryBuilder::PARAM_STR))
$qb->expr()->eq('p.author_id', $qb->createNamedParameter($authorId, IQueryBuilder::PARAM_STR))
)
->andWhere(
$qb->expr()->isNull('deleted_at')
$qb->expr()->isNull('p.deleted_at')
)
->andWhere(
$qb->expr()->isNull('t.deleted_at')
);
if ($excludeFirstPosts) {
$qb->andWhere(
$qb->expr()->eq('is_first_post', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
$qb->expr()->eq('p.is_first_post', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
);
}
$qb->orderBy('created_at', 'DESC')
$qb->orderBy('p.created_at', 'DESC')
->setMaxResults($limit)
->setFirstResult($offset);
return $this->findEntities($qb);
@@ -114,12 +118,16 @@ class PostMapper extends QBMapper {
public function findAll(): array {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
$qb->select('p.*')
->from($this->getTableName(), 'p')
->innerJoin('p', 'forum_threads', 't', $qb->expr()->eq('p.thread_id', 't.id'))
->where(
$qb->expr()->isNull('deleted_at')
$qb->expr()->isNull('p.deleted_at')
)
->orderBy('created_at', 'DESC');
->andWhere(
$qb->expr()->isNull('t.deleted_at')
)
->orderBy('p.created_at', 'DESC');
return $this->findEntities($qb);
}
@@ -129,9 +137,13 @@ class PostMapper extends QBMapper {
public function countAll(): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('*', 'count'))
->from($this->getTableName())
->from($this->getTableName(), 'p')
->innerJoin('p', 'forum_threads', 't', $qb->expr()->eq('p.thread_id', 't.id'))
->where(
$qb->expr()->isNull('deleted_at')
$qb->expr()->isNull('p.deleted_at')
)
->andWhere(
$qb->expr()->isNull('t.deleted_at')
);
$result = $qb->executeQuery();
$row = $result->fetch();
@@ -145,10 +157,14 @@ class PostMapper extends QBMapper {
public function countSince(int $timestamp): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('*', 'count'))
->from($this->getTableName())
->where($qb->expr()->gte('created_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)))
->from($this->getTableName(), 'p')
->innerJoin('p', 'forum_threads', 't', $qb->expr()->eq('p.thread_id', 't.id'))
->where($qb->expr()->gte('p.created_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)))
->andWhere(
$qb->expr()->isNull('deleted_at')
$qb->expr()->isNull('p.deleted_at')
)
->andWhere(
$qb->expr()->isNull('t.deleted_at')
);
$result = $qb->executeQuery();
$row = $result->fetch();
@@ -156,6 +172,34 @@ class PostMapper extends QBMapper {
return (int)($row['count'] ?? 0);
}
/**
* Find the latest non-deleted post in a thread, excluding a specific post ID
*
* @param int $threadId Thread ID
* @param int|null $excludePostId Post ID to exclude (typically the one being deleted)
* @return Post|null Latest post or null if no posts found
*/
public function findLatestByThreadId(int $threadId, ?int $excludePostId = null): ?Post {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->isNull('deleted_at'));
if ($excludePostId !== null) {
$qb->andWhere($qb->expr()->neq('id', $qb->createNamedParameter($excludePostId, IQueryBuilder::PARAM_INT)));
}
$qb->orderBy('created_at', 'DESC')
->setMaxResults(1);
try {
return $this->findEntity($qb);
} catch (DoesNotExistException $e) {
return null;
}
}
/**
* Count unread posts in a thread after a specific post ID
*

View File

@@ -56,35 +56,8 @@ class UserStatsService {
* @return array{users: int, updated: int, created: int} Statistics about the rebuild
*/
public function rebuildAllUserStats(): array {
// Get all users who have posted or created threads
$qb = $this->db->getQueryBuilder();
$qb->selectDistinct('author_id')
->from('forum_posts')
->where($qb->expr()->isNotNull('author_id'));
$result = $qb->executeQuery();
$users = [];
while ($row = $result->fetch()) {
$users[] = $row['author_id'];
}
$result->closeCursor();
$updated = 0;
$created = 0;
foreach ($users as $userId) {
$wasCreated = $this->rebuildUserStats($userId);
if ($wasCreated) {
$created++;
} else {
$updated++;
}
}
return [
'users' => count($users),
'updated' => $updated,
'created' => $created,
];
// Delegate to createStatsForAllUsers which processes all Nextcloud users
return $this->createStatsForAllUsers();
}
/**
@@ -94,30 +67,37 @@ class UserStatsService {
* @return bool True if stats were created, false if they were updated
*/
public function rebuildUserStats(string $userId): bool {
// Count threads created by this user
// Count non-deleted threads created by this user
$threadQb = $this->db->getQueryBuilder();
$threadQb->select($threadQb->func()->count('*', 'count'))
->from('forum_threads')
->where($threadQb->expr()->eq('author_id', $threadQb->createNamedParameter($userId)));
->where($threadQb->expr()->eq('author_id', $threadQb->createNamedParameter($userId)))
->andWhere($threadQb->expr()->isNull('deleted_at'));
$threadResult = $threadQb->executeQuery();
$threadCount = (int)($threadResult->fetchOne() ?? 0);
$threadResult->closeCursor();
// Count posts created by this user
// Count non-deleted posts created by this user (from non-deleted threads)
$postQb = $this->db->getQueryBuilder();
$postQb->select($postQb->func()->count('*', 'count'))
->from('forum_posts')
->where($postQb->expr()->eq('author_id', $postQb->createNamedParameter($userId)));
->from('forum_posts', 'p')
->innerJoin('p', 'forum_threads', 't', $postQb->expr()->eq('p.thread_id', 't.id'))
->where($postQb->expr()->eq('p.author_id', $postQb->createNamedParameter($userId)))
->andWhere($postQb->expr()->isNull('p.deleted_at'))
->andWhere($postQb->expr()->isNull('t.deleted_at'));
$postResult = $postQb->executeQuery();
$postCount = (int)($postResult->fetchOne() ?? 0);
$postResult->closeCursor();
// Get the timestamp of the last post
// Get the timestamp of the last non-deleted post (from non-deleted threads)
$lastPostQb = $this->db->getQueryBuilder();
$lastPostQb->select('created_at')
->from('forum_posts')
->where($lastPostQb->expr()->eq('author_id', $lastPostQb->createNamedParameter($userId)))
->orderBy('created_at', 'DESC')
$lastPostQb->select('p.created_at')
->from('forum_posts', 'p')
->innerJoin('p', 'forum_threads', 't', $lastPostQb->expr()->eq('p.thread_id', 't.id'))
->where($lastPostQb->expr()->eq('p.author_id', $lastPostQb->createNamedParameter($userId)))
->andWhere($lastPostQb->expr()->isNull('p.deleted_at'))
->andWhere($lastPostQb->expr()->isNull('t.deleted_at'))
->orderBy('p.created_at', 'DESC')
->setMaxResults(1);
$lastPostResult = $lastPostQb->executeQuery();
$lastPostAt = $lastPostResult->fetchOne();

View File

@@ -111,10 +111,11 @@ export default defineComponent({
cursor: inherit;
}
&:hover {
&:hover,
&.unread:hover,
&.pinned:hover {
border-color: var(--color-primary-element);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
&.pinned {

View File

@@ -1 +1 @@
0.2.0
0.2.1